From 4eae97a33b7b29bea7c67c588077610c16736b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 17 May 2026 15:03:38 +0800 Subject: [PATCH] feat(serve): add session-scoped permission route --- .../cli/qwen-serve-routes.test.ts | 1 + packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/httpAcpBridge.test.ts | 219 ++++++++++++++++++ packages/cli/src/serve/httpAcpBridge.ts | 132 ++++++++++- packages/cli/src/serve/server.test.ts | 150 ++++++++++++ packages/cli/src/serve/server.ts | 106 +++++++-- .../sdk-typescript/src/daemon/DaemonClient.ts | 44 ++++ .../src/daemon/DaemonSessionClient.ts | 12 + packages/sdk-typescript/src/daemon/events.ts | 43 +++- packages/sdk-typescript/src/daemon/index.ts | 2 + packages/sdk-typescript/src/index.ts | 2 + .../test/unit/DaemonClient.test.ts | 46 ++++ .../test/unit/DaemonSessionClient.test.ts | 10 + .../test/unit/daemonEvents.test.ts | 63 +++++ 14 files changed, 801 insertions(+), 30 deletions(-) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 71c020b309b..8123f08fef5 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -200,6 +200,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_events', 'session_set_model', 'client_identity', + 'session_permission_vote', 'permission_vote', ]); }); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index e022e27b74f..d1e7306e18b 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -38,6 +38,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_events: { since: 'v1' }, session_set_model: { since: 'v1' }, client_identity: { since: 'v1' }, + session_permission_vote: { since: 'v1' }, permission_vote: { since: 'v1' }, } as const satisfies Record; diff --git a/packages/cli/src/serve/httpAcpBridge.test.ts b/packages/cli/src/serve/httpAcpBridge.test.ts index 4c3d140cba7..70b2f42da30 100644 --- a/packages/cli/src/serve/httpAcpBridge.test.ts +++ b/packages/cli/src/serve/httpAcpBridge.test.ts @@ -2157,6 +2157,225 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('publishes permission_already_resolved when a scoped vote loses the race', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + void ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + const reqEvt = (await it.next()).value!; + const requestId = (reqEvt.data as { requestId: string }).requestId; + const accepted = bridge.respondToSessionPermission( + session.sessionId, + requestId, + { + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + { clientId: session.clientId }, + ); + expect(accepted).toBe(true); + const resolvedEvt = (await it.next()).value!; + expect(resolvedEvt.type).toBe('permission_resolved'); + + const second = bridge.respondToSessionPermission( + session.sessionId, + requestId, + { outcome: { outcome: 'cancelled' } }, + { clientId: session.clientId }, + ); + expect(second).toBe(false); + const alreadyEvt = (await it.next()).value!; + expect(alreadyEvt.type).toBe('permission_already_resolved'); + expect(alreadyEvt.originatorClientId).toBeUndefined(); + expect(alreadyEvt.data).toMatchObject({ + requestId, + sessionId: session.sessionId, + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('session-scoped permission votes cannot resolve another session request', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + void ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + const reqEvt = (await it.next()).value!; + const requestId = (reqEvt.data as { requestId: string }).requestId; + const wrongSession = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const accepted = bridge.respondToSessionPermission( + wrongSession.sessionId, + requestId, + { outcome: { outcome: 'selected', optionId: 'allow' } }, + { clientId: wrongSession.clientId }, + ); + expect(accepted).toBe(false); + expect(bridge.pendingPermissionCount).toBe(1); + expect( + bridge.respondToSessionPermission( + wrongSession.sessionId, + requestId, + { outcome: { outcome: 'cancelled' } }, + { clientId: 'client-not-issued' }, + ), + ).toBe(false); + expect(bridge.pendingPermissionCount).toBe(1); + + bridge.respondToPermission(requestId, { + outcome: { outcome: 'cancelled' }, + }); + expect(bridge.pendingPermissionCount).toBe(0); + subAbort.abort(); + await bridge.shutdown(); + }); + + it('session-scoped duplicate votes do not validate clients against another session', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + void ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'x' }, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + }); + + const it = iter[Symbol.asyncIterator](); + const reqEvt = (await it.next()).value!; + const requestId = (reqEvt.data as { requestId: string }).requestId; + expect( + bridge.respondToSessionPermission( + session.sessionId, + requestId, + { + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + { clientId: session.clientId }, + ), + ).toBe(true); + + const wrongSession = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect( + bridge.respondToSessionPermission( + wrongSession.sessionId, + requestId, + { outcome: { outcome: 'cancelled' } }, + { clientId: 'client-not-issued' }, + ), + ).toBe(false); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('respondToSessionPermission throws SessionNotFoundError for unknown sessions', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + + expect(() => + bridge.respondToSessionPermission('missing-session', 'req-1', { + outcome: { outcome: 'cancelled' }, + }), + ).toThrow(SessionNotFoundError); + + await bridge.shutdown(); + }); + + it('rejects scoped votes whose optionId was not in the agent-offered set', async () => { + const { bridge, session, conn } = await setupForPermission(); + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { toolCallId: 'tc-1', title: 'rm -rf /' }, + options: [ + { optionId: 'allow', name: 'Allow', kind: 'allow_once' }, + { optionId: 'deny', name: 'Deny', kind: 'reject_once' }, + ], + }); + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + const payload = next.value!.data as { requestId: string }; + + expect(() => + bridge.respondToSessionPermission( + session.sessionId, + payload.requestId, + { + outcome: { + outcome: 'selected', + optionId: 'ProceedAlwaysProject', + }, + }, + { clientId: session.clientId }, + ), + ).toThrow(InvalidPermissionOptionError); + + expect(bridge.pendingPermissionCount).toBe(1); + bridge.respondToSessionPermission( + session.sessionId, + payload.requestId, + { + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + { clientId: session.clientId }, + ); + const response = (await respPromise) as { + outcome: { outcome: string; optionId?: string }; + }; + expect(response.outcome.optionId).toBe('allow'); + + subAbort.abort(); + await bridge.shutdown(); + }); + it('rejects permission votes with unregistered client ids', async () => { const { bridge, session, conn } = await setupForPermission(); diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/httpAcpBridge.ts index 9670032a60e..e83d073c281 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/httpAcpBridge.ts @@ -213,6 +213,18 @@ export interface HttpAcpBridge { context?: BridgeClientRequestContext, ): boolean; + /** + * Cast a vote scoped to an explicit session route. This keeps the legacy + * first-responder behavior but lets clients avoid accidentally voting on a + * request id that belongs to another live session. + */ + respondToSessionPermission( + sessionId: string, + requestId: string, + response: RequestPermissionResponse, + context?: BridgeClientRequestContext, + ): boolean; + /** * List all live sessions whose canonical workspace path matches the * supplied cwd. Empty array (not throw) when no sessions exist — @@ -737,6 +749,27 @@ interface PendingPermission { allowedOptionIds: ReadonlySet; } +interface PermissionResolutionRecord { + requestId: string; + sessionId: string; + outcome: RequestPermissionResponse['outcome']; +} + +// Bounded duplicate-vote cache. Stores only requestId/sessionId/outcome, so +// 512 records stays small while covering normal UI reconnect/race windows. +const MAX_RESOLVED_PERMISSION_RECORDS = 512; + +function isServeDebugLoggingEnabled(): boolean { + const value = process.env['QWEN_SERVE_DEBUG']; + if (!value) return false; + return !['0', 'false', 'off', 'no'].includes(value.trim().toLowerCase()); +} + +function writeServeDebugLine(message: string): void { + if (!isServeDebugLoggingEnabled()) return; + writeStderrLine(`qwen serve debug: ${message}`); +} + /** * BkwQI: thrown by `bridge.respondToPermission` when the voter's * `optionId` isn't in the set of options the agent originally @@ -1291,6 +1324,8 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // Daemon-wide pending permission table; requestIds are UUIDs so collisions // across sessions are infeasible in practice. const pendingPermissions = new Map(); + const resolvedPermissions = new Map(); + const resolvedPermissionOrder: string[] = []; // Set by `shutdown()` so any in-flight `spawnOrAttach` that was // dispatched on an existing connection AFTER the shutdown snapshot // taken in `shutdown()` fails fast instead of creating a child the @@ -1398,6 +1433,44 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { entry.pendingPermissionIds.add(p.requestId); }; + const rememberResolvedPermission = (record: PermissionResolutionRecord) => { + if (!resolvedPermissions.has(record.requestId)) { + resolvedPermissionOrder.push(record.requestId); + } + resolvedPermissions.set(record.requestId, record); + while (resolvedPermissionOrder.length > MAX_RESOLVED_PERMISSION_RECORDS) { + const oldest = resolvedPermissionOrder.shift(); + if (oldest !== undefined) resolvedPermissions.delete(oldest); + } + }; + + const publishPermissionAlreadyResolved = ( + record: PermissionResolutionRecord, + ) => { + const entry = byId.get(record.sessionId); + if (!entry) return; + try { + writeServeDebugLine( + `permission ${JSON.stringify(record.requestId)} ` + + `for session ${JSON.stringify(record.sessionId)} was already ` + + 'resolved; publishing duplicate-vote notification.', + ); + entry.events.publish({ + type: 'permission_already_resolved', + data: { + requestId: record.requestId, + sessionId: record.sessionId, + outcome: record.outcome, + }, + }); + } catch { + writeServeDebugLine( + `skipped duplicate-vote notification for permission ` + + `${JSON.stringify(record.requestId)} during shutdown.`, + ); + } + }; + /** Resolve a single pending request and clean up its bookkeeping. */ const resolvePending = ( requestId: string, @@ -1423,6 +1496,11 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { /* bus closed during shutdown */ } } + rememberResolvedPermission({ + requestId, + sessionId: pending.sessionId, + outcome: response.outcome, + }); pending.resolve(response); return true; }; @@ -2570,6 +2648,13 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { resolveAnyTrustedClientId(context.clientId); } } + if (!pending) { + const record = resolvedPermissions.get(requestId); + if (record) { + publishPermissionAlreadyResolved(record); + } + return false; + } // BkwQI: validate the voter's optionId against the original // options the agent advertised. The route already enforces // "non-empty string" structurally; this layer enforces @@ -2578,10 +2663,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // `ProceedAlways*` when the prompt's `hideAlwaysAllow` // policy intentionally suppressed them). if (response.outcome.outcome === 'selected') { - if ( - pending && - !pending.allowedOptionIds.has(response.outcome.optionId) - ) { + if (!pending.allowedOptionIds.has(response.outcome.optionId)) { throw new InvalidPermissionOptionError( requestId, response.outcome.optionId, @@ -2591,6 +2673,48 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return resolvePending(requestId, response, originatorClientId); }, + respondToSessionPermission(sessionId, requestId, response, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const pending = pendingPermissions.get(requestId); + if (!pending) { + const record = resolvedPermissions.get(requestId); + if (record?.sessionId === sessionId) { + resolveTrustedClientId(entry, context?.clientId); + publishPermissionAlreadyResolved(record); + } else if (record) { + writeServeDebugLine( + `rejected permission vote ${JSON.stringify(requestId)} ` + + `for session ${JSON.stringify(sessionId)}; request belongs to ` + + `session ${JSON.stringify(record.sessionId)}.`, + ); + } + return false; + } + if (pending.sessionId !== sessionId) { + writeServeDebugLine( + `rejected permission vote ${JSON.stringify(requestId)} ` + + `for session ${JSON.stringify(sessionId)}; request belongs to ` + + `session ${JSON.stringify(pending.sessionId)}.`, + ); + return false; + } + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + if ( + response.outcome.outcome === 'selected' && + !pending.allowedOptionIds.has(response.outcome.optionId) + ) { + throw new InvalidPermissionOptionError( + requestId, + response.outcome.optionId, + ); + } + return resolvePending(requestId, response, originatorClientId); + }, + listWorkspaceSessions(workspaceCwd) { if (!path.isAbsolute(workspaceCwd)) return []; // fast-path: under §02 single-workspace, string equality diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 01fa01e94e9..e2aadac44eb 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -74,6 +74,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_events', 'session_set_model', 'client_identity', + 'session_permission_vote', 'permission_vote', ] as const; @@ -105,6 +106,12 @@ interface FakeBridgeOpts { response: RequestPermissionResponse, context?: BridgeClientRequestContext, ) => boolean; + sessionRespondImpl?: ( + sessionId: string, + requestId: string, + response: RequestPermissionResponse, + context?: BridgeClientRequestContext, + ) => boolean; listImpl?: (workspaceCwd: string) => BridgeSessionSummary[]; setModelImpl?: ( sessionId: string, @@ -138,6 +145,12 @@ interface FakeBridge extends HttpAcpBridge { response: RequestPermissionResponse; context?: BridgeClientRequestContext; }>; + sessionPermissionVotes: Array<{ + sessionId: string; + requestId: string; + response: RequestPermissionResponse; + context?: BridgeClientRequestContext; + }>; listCalls: string[]; setModelCalls: Array<{ sessionId: string; @@ -159,6 +172,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }> = []; const detachCalls: FakeBridge['detachCalls'] = []; const permissionVotes: FakeBridge['permissionVotes'] = []; + const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; const setModelCalls: FakeBridge['setModelCalls'] = []; let shutdownCalls = 0; @@ -192,6 +206,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { opts.promptImpl ?? (async () => ({ stopReason: 'end_turn' })); const cancelImpl = opts.cancelImpl ?? (async () => {}); const respondImpl = opts.respondImpl ?? (() => true); + const sessionRespondImpl = opts.sessionRespondImpl ?? (() => true); const listImpl = opts.listImpl ?? (() => []); const setModelImpl = opts.setModelImpl ?? (async () => ({})); return { @@ -203,6 +218,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { killCalls, detachCalls, permissionVotes, + sessionPermissionVotes, listCalls, setModelCalls, get shutdownCalls() { @@ -258,6 +274,21 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return accepted; }, + respondToSessionPermission(sessionId, requestId, response, context) { + const accepted = sessionRespondImpl( + sessionId, + requestId, + response, + context, + ); + sessionPermissionVotes.push({ + sessionId, + requestId, + response, + ...(context ? { context } : {}), + }); + return accepted; + }, listWorkspaceSessions(workspaceCwd) { listCalls.push(workspaceCwd); return listImpl(workspaceCwd); @@ -1288,6 +1319,125 @@ describe('createServeApp', () => { }); }); + describe('POST /session/:id/permission/:requestId', () => { + it('200 when bridge accepts the scoped vote', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected', optionId: 'allow' } }); + expect(res.status).toBe(200); + expect(bridge.sessionPermissionVotes).toEqual([ + { + sessionId: 'session-A', + requestId: 'req-1', + response: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }, + ]); + }); + + it('passes client identity context into scoped permission votes', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('X-Qwen-Client-Id', 'client-1') + .send({ outcome: { outcome: 'cancelled' } }); + expect(res.status).toBe(200); + expect(bridge.sessionPermissionVotes[0]?.context).toEqual({ + clientId: 'client-1', + }); + }); + + it('404 when bridge reports no pending scoped request', async () => { + const bridge = fakeBridge({ sessionRespondImpl: () => false }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/missing') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'cancelled' } }); + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ + sessionId: 'session-A', + requestId: 'missing', + }); + }); + + it('400 on a malformed scoped selected outcome', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected' } }); + expect(res.status).toBe(400); + expect(bridge.sessionPermissionVotes).toHaveLength(0); + }); + + it('400 when scoped outcome is missing entirely', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({}); + expect(res.status).toBe(400); + expect(bridge.sessionPermissionVotes).toHaveLength(0); + }); + + it('400 when scoped selected outcome has an empty-string optionId', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'selected', optionId: '' } }); + expect(res.status).toBe(400); + expect(bridge.sessionPermissionVotes).toHaveLength(0); + }); + + it('400 with invalid_option_id when bridge rejects a scoped option', async () => { + const bridge = fakeBridge({ + sessionRespondImpl: () => { + throw new InvalidPermissionOptionError( + 'req-1', + 'ProceedAlwaysProject', + ); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + outcome: { outcome: 'selected', optionId: 'ProceedAlwaysProject' }, + }); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_option_id', + requestId: 'req-1', + optionId: 'ProceedAlwaysProject', + }); + }); + + it('404 when bridge reports unknown session on scoped vote', async () => { + const bridge = fakeBridge({ + sessionRespondImpl: (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/permission/req-1') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ outcome: { outcome: 'cancelled' } }); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + }); + describe('POST /permission/:requestId', () => { it('200 when bridge accepts the vote', async () => { const bridge = fakeBridge(); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 1e98e415dcc..0c93dc65b95 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -72,6 +72,7 @@ export interface ServeAppDeps { * - `POST /session/:id/cancel` * - `POST /session/:id/model` * - `GET /session/:id/events` (SSE) + * - `POST /session/:id/permission/:requestId` * - `POST /permission/:requestId` * * **Workspace validation contract.** `createServeApp` itself does NOT @@ -611,44 +612,56 @@ export function createServeApp( } }); - app.post('/permission/:requestId', (req, res) => { + app.post('/session/:id/permission/:requestId', (req, res) => { + const sessionId = req.params['id']; const requestId = req.params['requestId']; - const body = safeBody(req); - const outcome = body['outcome']; - if (!isValidOutcome(outcome)) { - res.status(400).json({ - error: - '`outcome` must be `{ outcome: "cancelled" }` or `{ outcome: "selected", optionId: string }`', + const response = parsePermissionVoteBody(req, res); + if (response === undefined) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + let accepted: boolean; + try { + accepted = bridge.respondToSessionPermission( + sessionId, + requestId, + response, + clientId !== undefined ? { clientId } : undefined, + ); + } catch (err) { + sendPermissionVoteError(res, err, { + route: 'POST /session/:id/permission/:requestId', + sessionId, }); return; } + if (!accepted) { + res.status(404).json({ + error: 'No pending permission request for session', + sessionId, + requestId, + }); + return; + } + res.status(200).json({}); + }); + + app.post('/permission/:requestId', (req, res) => { + const requestId = req.params['requestId']; + const response = parsePermissionVoteBody(req, res); + if (response === undefined) return; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; let accepted: boolean; try { accepted = bridge.respondToPermission( requestId, - { - ...(body as object), - outcome, - } as Parameters[1], + response, clientId !== undefined ? { clientId } : undefined, ); } catch (err) { - // BkwQI: voter's `optionId` wasn't in the option set the agent - // originally offered (e.g. forging `ProceedAlways*` when the - // prompt's `hideAlwaysAllow` policy suppressed it). 400, not - // 404 — the requestId IS known, but the chosen option isn't. - if (err instanceof InvalidPermissionOptionError) { - res.status(400).json({ - error: err.message, - code: 'invalid_option_id', - requestId: err.requestId, - optionId: err.optionId, - }); - return; - } - sendBridgeError(res, err, { route: 'POST /permission/:requestId' }); + sendPermissionVoteError(res, err, { + route: 'POST /permission/:requestId', + }); return; } if (!accepted) { @@ -948,6 +961,12 @@ const PROTOTYPE_POLLUTION_KEYS: ReadonlySet = new Set([ const CLIENT_ID_HEADER = 'x-qwen-client-id'; const MAX_CLIENT_ID_LENGTH = 128; const CLIENT_ID_RE = /^[A-Za-z0-9._:-]+$/; +const INVALID_PERMISSION_OUTCOME_ERROR = + '`outcome` must be `{ outcome: "cancelled" }` or `{ outcome: "selected", optionId: string }`'; + +type PermissionVoteResponse = Parameters< + HttpAcpBridge['respondToPermission'] +>[1]; /** * Coerce `req.body` into a safe `Record` for route @@ -1018,6 +1037,22 @@ function parseClientIdHeader( return raw; } +function parsePermissionVoteBody( + req: import('express').Request, + res: import('express').Response, +): PermissionVoteResponse | undefined { + const body = safeBody(req); + const outcome = body['outcome']; + if (!isValidOutcome(outcome)) { + res.status(400).json({ error: INVALID_PERMISSION_OUTCOME_ERROR }); + return undefined; + } + return { + ...(body as object), + outcome, + } as PermissionVoteResponse; +} + function isValidOutcome( raw: unknown, ): raw is { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } { @@ -1067,6 +1102,27 @@ function parseLastEventId(raw: unknown): number | undefined { return n; } +function sendPermissionVoteError( + res: import('express').Response, + err: unknown, + ctx: { route: string; sessionId?: string }, +): void { + // BkwQI: voter's `optionId` wasn't in the option set the agent + // originally offered (e.g. forging `ProceedAlways*` when the + // prompt's `hideAlwaysAllow` policy suppressed it). 400, not + // 404 — the requestId IS known, but the chosen option isn't. + if (err instanceof InvalidPermissionOptionError) { + res.status(400).json({ + error: err.message, + code: 'invalid_option_id', + requestId: err.requestId, + optionId: err.optionId, + }); + return; + } + sendBridgeError(res, err, ctx); +} + function formatSseFrame(event: BridgeEvent | OmitId): string { // SSE format: id (optional), event (optional), data, blank line. // The `id:` line is intentionally omitted when `event.id` is absent — diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1ee89b99757..bc6fd4ae784 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -601,6 +601,50 @@ export class DaemonClient { }, ); } + + /** + * Cast a permission vote against an explicit daemon session. New clients + * should prefer this once `capabilities.features` includes + * `session_permission_vote`; the legacy request-id-only route remains for + * older daemons. + */ + async respondToSessionPermission( + sessionId: string, + requestId: string, + response: PermissionResponse, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/permission/${encodeURIComponent(requestId)}`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify(response), + }, + async (res) => { + if (res.status === 200) { + try { + await res.body?.cancel(); + } catch { + /* body already consumed or no body */ + } + return true; + } + if (res.status === 404) { + try { + await res.body?.cancel(); + } catch { + /* body already consumed or no body */ + } + return false; + } + throw await this.failOnError( + res, + 'POST /session/:id/permission/:requestId', + ); + }, + ); + } } /** diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 5cac0e40dbe..39528a13230 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -193,6 +193,18 @@ export class DaemonSessionClient { ); } + async respondToSessionPermission( + requestId: string, + response: PermissionResponse, + ): Promise { + return await this.client.respondToSessionPermission( + this.sessionId, + requestId, + response, + this.clientId, + ); + } + events( opts: DaemonSessionSubscribeOptions = {}, ): AsyncGenerator { diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 13e555f6a25..ad81ff40ed4 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -10,6 +10,7 @@ const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ 'session_update', 'permission_request', 'permission_resolved', + 'permission_already_resolved', 'model_switched', 'model_switch_failed', 'session_died', @@ -53,6 +54,13 @@ export interface DaemonPermissionResolvedData { [key: string]: unknown; } +export interface DaemonPermissionAlreadyResolvedData { + requestId: string; + sessionId: string; + outcome: PermissionOutcome; + [key: string]: unknown; +} + export interface DaemonModelSwitchedData { sessionId: string; modelId: string; @@ -97,6 +105,10 @@ export type DaemonPermissionResolvedEvent = DaemonEventEnvelope< 'permission_resolved', DaemonPermissionResolvedData >; +export type DaemonPermissionAlreadyResolvedEvent = DaemonEventEnvelope< + 'permission_already_resolved', + DaemonPermissionAlreadyResolvedData +>; export type DaemonModelSwitchedEvent = DaemonEventEnvelope< 'model_switched', DaemonModelSwitchedData @@ -126,7 +138,8 @@ export type DaemonSessionEvent = export type DaemonControlEvent = | DaemonPermissionRequestEvent - | DaemonPermissionResolvedEvent; + | DaemonPermissionResolvedEvent + | DaemonPermissionAlreadyResolvedEvent; export type DaemonStreamLifecycleEvent = | DaemonClientEvictedEvent @@ -217,6 +230,10 @@ export function asKnownDaemonEvent( return isPermissionResolvedData(event.data) ? (event as DaemonPermissionResolvedEvent) : undefined; + case 'permission_already_resolved': + return isPermissionAlreadyResolvedData(event.data) + ? (event as DaemonPermissionAlreadyResolvedEvent) + : undefined; case 'model_switched': return isModelSwitchedData(event.data) ? (event as DaemonModelSwitchedEvent) @@ -300,6 +317,19 @@ export function reduceDaemonSessionEvent( delete pendingPermissions[event.data.requestId]; return { ...base, pendingPermissions }; } + case 'permission_already_resolved': { + if (!(event.data.requestId in base.pendingPermissions)) { + return { + ...base, + unmatchedPermissionResolutionCount: + base.unmatchedPermissionResolutionCount + 1, + lastUnmatchedPermissionResolutionId: event.data.requestId, + }; + } + const pendingPermissions = { ...base.pendingPermissions }; + delete pendingPermissions[event.data.requestId]; + return { ...base, pendingPermissions }; + } case 'model_switched': return { ...base, @@ -401,6 +431,17 @@ function isPermissionResolvedData( ); } +function isPermissionAlreadyResolvedData( + value: unknown, +): value is DaemonPermissionAlreadyResolvedData { + return ( + isRecord(value) && + isNonEmptyString(value['requestId']) && + isNonEmptyString(value['sessionId']) && + isPermissionOutcome(value['outcome']) + ); +} + function isModelSwitchedData(value: unknown): value is DaemonModelSwitchedData { return ( isRecord(value) && diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index d6c1c12f2a4..b2a138dca08 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -39,6 +39,8 @@ export type { DaemonModelSwitchFailedData, DaemonModelSwitchFailedEvent, DaemonPermissionOption, + DaemonPermissionAlreadyResolvedData, + DaemonPermissionAlreadyResolvedEvent, DaemonPermissionRequestData, DaemonPermissionRequestEvent, DaemonPermissionResolvedData, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index cd15e0b010e..5eeae4e059c 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -33,6 +33,8 @@ export { type DaemonModelSwitchFailedData, type DaemonModelSwitchFailedEvent, type DaemonPermissionOption, + type DaemonPermissionAlreadyResolvedData, + type DaemonPermissionAlreadyResolvedEvent, type DaemonPermissionRequestData, type DaemonPermissionRequestEvent, type DaemonPermissionResolvedData, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index aa4b188d665..9f229f591f6 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -508,6 +508,52 @@ describe('DaemonClient', () => { }), ).rejects.toMatchObject({ status: 400 }); }); + + it('POSTs session-scoped permission votes', async () => { + const { fetch, calls } = recordingFetch(() => jsonResponse(200, {})); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const accepted = await client.respondToSessionPermission( + 's-1', + 'req/1', + { outcome: { outcome: 'cancelled' } }, + 'client-1', + ); + expect(accepted).toBe(true); + expect(calls[0]?.url).toBe( + 'http://daemon/session/s-1/permission/req%2F1', + ); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + + it('returns false on session-scoped permission 404', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(404, { error: 'missing' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.respondToSessionPermission('s-1', 'missing', { + outcome: { outcome: 'cancelled' }, + }), + ).resolves.toBe(false); + }); + + it('respondToSessionPermission throws on non-200/non-404 responses', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(400, { + error: 'bad option', + code: 'invalid_option_id', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.respondToSessionPermission('s-1', 'req-1', { + outcome: { outcome: 'cancelled' }, + }), + ).rejects.toMatchObject({ + status: 400, + body: { error: 'bad option', code: 'invalid_option_id' }, + }); + }); }); describe('subscribeEvents', () => { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 631d2cc0852..05d946841e3 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -297,6 +297,9 @@ describe('DaemonSessionClient', () => { if (req.url.endsWith('/permission/req-1')) { return jsonResponse(200, {}); } + if (req.url.endsWith('/session/s-1/permission/req-2')) { + return jsonResponse(200, {}); + } return jsonResponse(500, { error: `unexpected ${req.url}` }); }); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); @@ -326,12 +329,18 @@ describe('DaemonSessionClient', () => { outcome: { outcome: 'selected', optionId: 'allow' }, }), ).resolves.toBe(true); + await expect( + session.respondToSessionPermission('req-2', { + outcome: { outcome: 'cancelled' }, + }), + ).resolves.toBe(true); expect(calls.map((c) => c.url)).toEqual([ 'http://daemon/session/s-1/prompt', 'http://daemon/session/s-1/model', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', + 'http://daemon/session/s-1/permission/req-2', ]); expect(calls[0]?.signal).toBe(controller.signal); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ @@ -339,6 +348,7 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 6a65e4f2996..28a35fe7307 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -129,6 +129,18 @@ describe('daemon event schema', () => { }, }), ).toBeUndefined(); + + expect( + asKnownDaemonEvent({ + id: 10, + v: 1, + type: 'permission_already_resolved', + data: { + requestId: 'req-1', + outcome: { outcome: 'cancelled' }, + }, + }), + ).toBeUndefined(); }); it('reduces permission, model, and terminal events into a session view', () => { @@ -339,6 +351,57 @@ describe('daemon event schema', () => { expect(unmatched.lastUnmatchedPermissionResolutionId).toBe('missing-req'); }); + it('treats permission_already_resolved as an idempotent pending cleanup', () => { + const state = reduceDaemonSessionEvents([ + { + id: 1, + v: 1, + type: 'permission_request', + data: { + requestId: 'req-1', + sessionId: 's-1', + toolCall: { name: 'write_file' }, + options: [{ optionId: 'allow' }], + }, + }, + { + id: 2, + v: 1, + type: 'permission_already_resolved', + data: { + requestId: 'req-1', + sessionId: 's-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + }, + ]); + + expect(state.sessionId).toBe('s-1'); + expect(state.pendingPermissions).toEqual({}); + expect(state.unmatchedPermissionResolutionCount).toBe(0); + }); + + it('tracks unmatched permission_already_resolved without rewriting session identity', () => { + const state = reduceDaemonSessionEvent( + createDaemonSessionViewState({ sessionId: 's-current' }), + { + id: 1, + v: 1, + type: 'permission_already_resolved', + data: { + requestId: 'missing-req', + sessionId: 's-other', + outcome: { outcome: 'cancelled' }, + }, + }, + ); + + expect(state.sessionId).toBe('s-current'); + expect(state.pendingPermissions).toEqual({}); + expect(state.unmatchedPermissionResolutionCount).toBe(1); + expect(state.lastUnmatchedPermissionResolutionId).toBe('missing-req'); + }); + it('caps tracked pending permissions at the daemon session limit', () => { const requests: DaemonEvent[] = Array.from({ length: 65 }, (_, index) => ({ id: index + 1,