Skip to content

Commit c6cc66a

Browse files
committed
fix: harden debug chat auth and cancellation
1 parent 72510fa commit c6cc66a

5 files changed

Lines changed: 163 additions & 19 deletions

File tree

api/acp_bootstrap.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,23 @@ func (c *acpBootstrapInstanceClient) writeJSON(ctx context.Context, payload any)
198198
return c.conn.WriteJSON(payload)
199199
}
200200

201+
func (c *acpBootstrapInstanceClient) notify(ctx context.Context, method string, params any) error {
202+
return c.writeJSON(ctx, map[string]any{
203+
"jsonrpc": "2.0",
204+
"method": method,
205+
"params": params,
206+
})
207+
}
208+
209+
func (c *acpBootstrapInstanceClient) cancelPrompt(ctx context.Context, sessionID string) error {
210+
if strings.TrimSpace(sessionID) == "" {
211+
return nil
212+
}
213+
return c.notify(ctx, "session/cancel", map[string]any{
214+
"sessionId": sessionID,
215+
})
216+
}
217+
201218
func (c *acpBootstrapInstanceClient) readMessage(ctx context.Context) (*acpBootstrapJSONRPCMessage, error) {
202219
if deadline, ok := ctx.Deadline(); ok {
203220
if err := c.conn.SetReadDeadline(deadline); err != nil {

api/internal_debug_chat.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log"
99
"net/http"
1010
"strings"
11+
"sync"
1112
"time"
1213

1314
"github.com/gorilla/websocket"
@@ -209,6 +210,16 @@ func (s *server) cleanupInternalDebugConversation(ctx context.Context, conversat
209210
func (s *server) runInternalDebugChatPrompt(ctx context.Context, spritz *spritzv1.Spritz, sessionID, message string) (*acpPromptResult, error) {
210211
runCtx, cancel := context.WithTimeout(ctx, s.acp.promptTimeout)
211212
defer cancel()
213+
cancelWatcherDone := make(chan struct{})
214+
defer close(cancelWatcherDone)
215+
var cancelOnce sync.Once
216+
sendCancel := func() {
217+
cancelOnce.Do(func() {
218+
cancelCtx, cancelPrompt := context.WithTimeout(context.Background(), 5*time.Second)
219+
_ = s.cancelInternalDebugChatPrompt(cancelCtx, spritz, sessionID)
220+
cancelPrompt()
221+
})
222+
}
212223

213224
dialCtx, dialCancel := context.WithTimeout(runCtx, s.acp.bootstrapDialTimeout)
214225
defer dialCancel()
@@ -221,11 +232,48 @@ func (s *server) runInternalDebugChatPrompt(ctx context.Context, spritz *spritzv
221232
defer func() {
222233
_ = client.close()
223234
}()
235+
go func() {
236+
select {
237+
case <-runCtx.Done():
238+
select {
239+
case <-cancelWatcherDone:
240+
return
241+
default:
242+
}
243+
sendCancel()
244+
case <-cancelWatcherDone:
245+
}
246+
}()
224247

225248
if _, err := client.initialize(runCtx, s.acp.clientInfo, s.acp.clientCapabilities); err != nil {
226249
return nil, err
227250
}
228-
return client.prompt(runCtx, sessionID, message, s.acp.promptSettleTimeout)
251+
result, err := client.prompt(runCtx, sessionID, message, s.acp.promptSettleTimeout)
252+
if err != nil && runCtx.Err() != nil {
253+
sendCancel()
254+
}
255+
return result, err
256+
}
257+
258+
func (s *server) cancelInternalDebugChatPrompt(ctx context.Context, spritz *spritzv1.Spritz, sessionID string) error {
259+
if spritz == nil || strings.TrimSpace(sessionID) == "" {
260+
return nil
261+
}
262+
dialCtx, cancel := context.WithTimeout(ctx, s.acp.bootstrapDialTimeout)
263+
defer cancel()
264+
265+
instanceConn, _, err := websocket.DefaultDialer.DialContext(dialCtx, s.acpInstanceURL(spritz.Namespace, spritz.Name), nil)
266+
if err != nil {
267+
return err
268+
}
269+
client := &acpBootstrapInstanceClient{conn: instanceConn}
270+
defer func() {
271+
_ = client.close()
272+
}()
273+
if _, err := client.initialize(ctx, s.acp.clientInfo, s.acp.clientCapabilities); err != nil {
274+
return err
275+
}
276+
return client.cancelPrompt(ctx, sessionID)
229277
}
230278

231279
func (s *server) auditInternalDebugChat(actorID string, conversation *spritzv1.SpritzConversation, reason, message string, result *acpPromptResult) {

api/internal_debug_chat_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type fakeACPDebugChatServerOptions struct {
2121
SessionID string
2222
LoadReplay []map[string]any
2323
LoadReplayAfter []map[string]any
24+
HoldPrompt bool
2425
PromptChunks []string
2526
PromptError *acpBootstrapJSONRPCError
2627
StopReason string
@@ -34,6 +35,7 @@ type fakeACPDebugChatServer struct {
3435

3536
initCalls int
3637
newCalls int
38+
cancelSessionIDs []string
3739
loadSessionIDs []string
3840
promptSessionIDs []string
3941
promptTexts []string
@@ -162,6 +164,9 @@ func newFakeACPDebugChatServer(t *testing.T, options fakeACPDebugChatServerOptio
162164
fakeServer.promptSessionIDs = append(fakeServer.promptSessionIDs, params.SessionID)
163165
fakeServer.promptTexts = append(fakeServer.promptTexts, text)
164166
fakeServer.mu.Unlock()
167+
if options.HoldPrompt {
168+
continue
169+
}
165170
if options.PromptError != nil {
166171
if err := conn.WriteJSON(map[string]any{
167172
"jsonrpc": "2.0",
@@ -202,6 +207,16 @@ func newFakeACPDebugChatServer(t *testing.T, options fakeACPDebugChatServerOptio
202207
}); err != nil {
203208
t.Fatalf("failed to write prompt result: %v", err)
204209
}
210+
case "session/cancel":
211+
var params struct {
212+
SessionID string `json:"sessionId"`
213+
}
214+
if err := json.Unmarshal(message.Params, &params); err != nil {
215+
t.Fatalf("failed to decode cancel params: %v", err)
216+
}
217+
fakeServer.mu.Lock()
218+
fakeServer.cancelSessionIDs = append(fakeServer.cancelSessionIDs, params.SessionID)
219+
fakeServer.mu.Unlock()
205220
default:
206221
t.Fatalf("unexpected ACP method %q", message.Method)
207222
}
@@ -458,6 +473,44 @@ func TestCleanupInternalDebugConversationUsesFallbackContextWhenCanceled(t *test
458473
}
459474
}
460475

476+
func TestInternalDebugChatSendCancelsPromptWhenPromptTimesOut(t *testing.T) {
477+
spritz := readyACPSpritz("tidy-otter", "user-1")
478+
fakeACP := newFakeACPDebugChatServer(t, fakeACPDebugChatServerOptions{
479+
SessionID: "session-fresh",
480+
HoldPrompt: true,
481+
})
482+
483+
s := newACPTestServer(t, spritz)
484+
s.internalAuth = internalAuthConfig{enabled: true, token: "internal-token"}
485+
s.acp.instanceURL = func(namespace, name string) string { return fakeACP.url }
486+
s.acp.promptTimeout = 20 * time.Millisecond
487+
488+
e := echo.New()
489+
internal := e.Group("", s.internalAuthMiddleware(), s.authMiddleware())
490+
internal.POST("/api/internal/v1/debug/chat/send", s.sendInternalDebugChat)
491+
492+
req := httptest.NewRequest(http.MethodPost, "/api/internal/v1/debug/chat/send", strings.NewReader(`{
493+
"target":{"spritzName":"tidy-otter","cwd":"/workspace/app","title":"Debug Run"},
494+
"message":"hello from cli"
495+
}`))
496+
req.Header.Set(internalTokenHeader, "internal-token")
497+
req.Header.Set("Content-Type", "application/json")
498+
req.Header.Set("X-Spritz-User-Id", "user-1")
499+
rec := httptest.NewRecorder()
500+
e.ServeHTTP(rec, req)
501+
502+
if rec.Code != http.StatusBadGateway {
503+
t.Fatalf("expected status 502, got %d: %s", rec.Code, rec.Body.String())
504+
}
505+
time.Sleep(20 * time.Millisecond)
506+
507+
fakeACP.mu.Lock()
508+
defer fakeACP.mu.Unlock()
509+
if len(fakeACP.cancelSessionIDs) != 1 || fakeACP.cancelSessionIDs[0] != "session-fresh" {
510+
t.Fatalf("expected one session/cancel for session-fresh, got %#v", fakeACP.cancelSessionIDs)
511+
}
512+
}
513+
461514
func TestDebugChatRouteIsUnavailableWithoutAuthOrInternalAuth(t *testing.T) {
462515
s := newACPTestServer(t)
463516
s.auth = authConfig{mode: authModeNone}

cli/src/index.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,13 @@ function chatSendUsage() {
481481
console.log(`Spritz chat send
482482
483483
Usage:
484-
spritz chat send (--instance <name> | --conversation <id>) --message <text> [--owner-id <id>] [--reason <text>] [--cwd <path>] [--title <title>] [--namespace <ns>] [--json]
484+
spritz chat send (--instance <name> | --conversation <id>) --message <text> [--reason <text>] [--cwd <path>] [--title <title>] [--namespace <ns>] [--json]
485485
486486
Environment:
487487
SPRITZ_API_URL (default: ${process.env.SPRITZ_API_URL || defaultApiBase})
488488
SPRITZ_INTERNAL_TOKEN
489489
SPRITZ_INTERNAL_REQUEST_TIMEOUT_MS
490-
SPRITZ_OWNER_ID, SPRITZ_USER_ID, SPRITZ_PROFILE
490+
SPRITZ_USER_ID, SPRITZ_PROFILE
491491
492492
Notes:
493493
--instance creates a new owner-scoped conversation before sending the prompt.
@@ -507,7 +507,7 @@ Usage:
507507
spritz open <name> [--namespace <ns>]
508508
spritz terminal <name> [--namespace <ns>] [--session <name>] [--transport <ws|ssh>] [--print]
509509
spritz ssh <name> [--namespace <ns>] [--session <name>] [--transport <ws|ssh>] [--print]
510-
spritz chat send (--instance <name> | --conversation <id>) --message <text> [--owner-id <id>] [--reason <text>] [--cwd <path>] [--title <title>] [--namespace <ns>] [--json]
510+
spritz chat send (--instance <name> | --conversation <id>) --message <text> [--reason <text>] [--cwd <path>] [--title <title>] [--namespace <ns>] [--json]
511511
spritz profile list
512512
spritz profile current
513513
spritz profile show [name]
@@ -1468,22 +1468,16 @@ async function main() {
14681468
if (conversationId && (argValue('--cwd') || argValue('--title'))) {
14691469
throw new Error('--cwd and --title are only supported with --instance');
14701470
}
1471+
if (argValue('--owner-id')?.trim()) {
1472+
throw new Error('--owner-id is not supported for chat send; authenticate as the caller instead');
1473+
}
14711474
const message = argValue('--message');
14721475
if (!message?.trim()) {
14731476
throw new Error('--message is required');
14741477
}
1475-
const { profile } = await resolveProfile({ allowFlag: true });
1476-
const bearerToken = argValue('--token') || process.env.SPRITZ_BEARER_TOKEN || profile?.bearerToken;
1477-
const ownerId = argValue('--owner-id')?.trim() || (!bearerToken?.trim() ? await resolveDefaultOwnerId() : undefined);
1478-
if (!bearerToken?.trim() && !ownerId) {
1479-
throw new Error('owner id is required when bearer auth is unavailable; use --owner-id or set SPRITZ_OWNER_ID / SPRITZ_USER_ID');
1480-
}
14811478
const ns = await resolveNamespace();
14821479
const reason = argValue('--reason')?.trim() || 'spz chat send';
14831480
const requestHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
1484-
if (!bearerToken?.trim() && ownerId) {
1485-
requestHeaders[headerId] = ownerId;
1486-
}
14871481
const data = await internalRequest('/internal/v1/debug/chat/send', {
14881482
method: 'POST',
14891483
headers: requestHeaders,

cli/test/chat-send.test.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ test('chat send uses the internal token and prints assistant text by default', a
5656
'tidy-otter',
5757
'--message',
5858
' hello from cli ',
59-
'--owner-id',
60-
'user-123',
6159
'--cwd',
6260
'/workspace/app',
6361
'--title',
@@ -70,6 +68,7 @@ test('chat send uses the internal token and prints assistant text by default', a
7068
...process.env,
7169
SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`,
7270
SPRITZ_INTERNAL_TOKEN: 'internal-token',
71+
SPRITZ_USER_ID: 'user-123',
7372
SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')),
7473
},
7574
stdio: ['ignore', 'pipe', 'pipe'],
@@ -144,15 +143,14 @@ test('chat send supports existing conversations and json output', async (t) => {
144143
'tidy-otter-conv',
145144
'--message',
146145
'follow up',
147-
'--owner-id',
148-
'user-123',
149146
'--json',
150147
],
151148
{
152149
env: {
153150
...process.env,
154151
SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`,
155152
SPRITZ_INTERNAL_TOKEN: 'internal-token',
153+
SPRITZ_USER_ID: 'user-123',
156154
SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')),
157155
},
158156
stdio: ['ignore', 'pipe', 'pipe'],
@@ -231,8 +229,6 @@ test('chat send does not inject owner header when bearer auth comes from the act
231229
'tidy-otter',
232230
'--message',
233231
'hello from cli',
234-
'--owner-id',
235-
'victim-id',
236232
],
237233
{
238234
env: {
@@ -255,6 +251,42 @@ test('chat send does not inject owner header when bearer auth comes from the act
255251
assert.equal(requestHeaders?.['x-spritz-user-id'], undefined);
256252
});
257253

254+
test('chat send rejects --owner-id as an auth source', async () => {
255+
const child = spawn(
256+
process.execPath,
257+
[
258+
'--import',
259+
'tsx',
260+
cliPath,
261+
'chat',
262+
'send',
263+
'--instance',
264+
'tidy-otter',
265+
'--message',
266+
'hello from cli',
267+
'--owner-id',
268+
'victim-id',
269+
],
270+
{
271+
env: {
272+
...process.env,
273+
SPRITZ_INTERNAL_TOKEN: 'internal-token',
274+
SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')),
275+
},
276+
stdio: ['ignore', 'pipe', 'pipe'],
277+
},
278+
);
279+
280+
let stderr = '';
281+
child.stderr.on('data', (chunk) => {
282+
stderr += chunk.toString();
283+
});
284+
285+
const exitCode = await new Promise<number | null>((resolve) => child.on('exit', resolve));
286+
assert.notEqual(exitCode, 0, 'spz chat send should reject --owner-id');
287+
assert.match(stderr, /--owner-id is not supported for chat send/);
288+
});
289+
258290
test('chat send does not require an owner id when bearer auth is available', async (t) => {
259291
let requestHeaders: http.IncomingHttpHeaders | null = null;
260292

0 commit comments

Comments
 (0)