Repository navigation
feat(serve): add daemon-stamped client identity #4231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ import type { | |
| } from '@agentclientprotocol/sdk'; | ||
| import { | ||
| createHttpAcpBridge, | ||
| InvalidClientIdError, | ||
| InvalidPermissionOptionError, | ||
| InvalidSessionScopeError, | ||
| MAX_WORKSPACE_PATH_LENGTH, | ||
|
|
@@ -280,6 +281,7 @@ describe('createHttpAcpBridge', () => { | |
| expect(session.sessionId).toBe(SESS_A); | ||
| expect(session.workspaceCwd).toBe(WS_A); | ||
| expect(session.attached).toBe(false); | ||
| expect(session.clientId).toMatch(/^client_/); | ||
| expect(bridge.sessionCount).toBe(1); | ||
| expect(handles).toHaveLength(1); | ||
| expect(handles[0]?.agent.newSessionCalls[0]?.cwd).toBe(WS_A); | ||
|
|
@@ -303,12 +305,36 @@ describe('createHttpAcpBridge', () => { | |
| expect(first.sessionId).toBe(second.sessionId); | ||
| expect(first.attached).toBe(false); | ||
| expect(second.attached).toBe(true); | ||
| expect(first.clientId).toMatch(/^client_/); | ||
| expect(second.clientId).toMatch(/^client_/); | ||
| expect(second.clientId).not.toBe(first.clientId); | ||
| expect(handles).toHaveLength(1); // only one child spawned | ||
| expect(bridge.sessionCount).toBe(1); | ||
|
|
||
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('reuses an echoed daemon-issued client id on attach', async () => { | ||
| const handles: ChannelHandle[] = []; | ||
| const factory: ChannelFactory = async () => { | ||
| const h = makeChannel(); | ||
| handles.push(h); | ||
| return h.channel; | ||
| }; | ||
| const bridge = makeBridge({ channelFactory: factory }); | ||
| const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); | ||
| const second = await bridge.spawnOrAttach({ | ||
| workspaceCwd: WS_A, | ||
| clientId: first.clientId, | ||
| }); | ||
|
|
||
| expect(second.attached).toBe(true); | ||
| expect(second.clientId).toBe(first.clientId); | ||
|
|
||
| await bridge.shutdown(); | ||
| expect(handles[0]?.killed).toBe(true); | ||
| }); | ||
|
|
||
| it('loads an existing ACP session and registers it for daemon routes', async () => { | ||
| const handles: ChannelHandle[] = []; | ||
| const factory: ChannelFactory = async () => { | ||
|
|
@@ -329,6 +355,7 @@ describe('createHttpAcpBridge', () => { | |
| sessionId: 'persisted-1', | ||
| workspaceCwd: WS_A, | ||
| attached: false, | ||
| clientId: expect.stringMatching(/^client_/), | ||
| state: { configOptions: [] }, | ||
| }); | ||
| expect(handles[0]?.agent.loadSessionCalls).toEqual([ | ||
|
|
@@ -428,6 +455,7 @@ describe('createHttpAcpBridge', () => { | |
| sessionId: 'persisted-2', | ||
| workspaceCwd: WS_A, | ||
| attached: false, | ||
| clientId: expect.stringMatching(/^client_/), | ||
| state: { modes: null }, | ||
| }); | ||
| expect(handles[0]?.agent.loadSessionCalls).toHaveLength(0); | ||
|
|
@@ -469,8 +497,10 @@ describe('createHttpAcpBridge', () => { | |
| sessionId: 'persisted-3', | ||
| workspaceCwd: WS_A, | ||
| attached: true, | ||
| clientId: expect.stringMatching(/^client_/), | ||
| state: { _meta: { tag: 'restored-foo' } }, | ||
| }); | ||
| expect(attached.clientId).not.toBe(loaded.clientId); | ||
| expect(handles[0]?.agent.loadSessionCalls).toHaveLength(1); | ||
| expect(handles[0]?.agent.resumeSessionCalls).toHaveLength(0); | ||
|
|
||
|
|
@@ -2044,12 +2074,17 @@ describe('createHttpAcpBridge', () => { | |
| const it = iter[Symbol.asyncIterator](); | ||
| const reqEvt = (await it.next()).value!; | ||
| const requestId = (reqEvt.data as { requestId: string }).requestId; | ||
| bridge.respondToPermission(requestId, { | ||
| outcome: { outcome: 'selected', optionId: 'allow' }, | ||
| }); | ||
| bridge.respondToPermission( | ||
| requestId, | ||
| { | ||
| outcome: { outcome: 'selected', optionId: 'allow' }, | ||
| }, | ||
| { clientId: session.clientId }, | ||
| ); | ||
|
|
||
| const resolvedEvt = (await it.next()).value!; | ||
| expect(resolvedEvt.type).toBe('permission_resolved'); | ||
| expect(resolvedEvt.originatorClientId).toBe(session.clientId); | ||
| expect(resolvedEvt.data).toMatchObject({ | ||
| requestId, | ||
| outcome: { outcome: 'selected', optionId: 'allow' }, | ||
|
|
@@ -2059,6 +2094,40 @@ describe('createHttpAcpBridge', () => { | |
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('rejects permission votes with unregistered client ids', 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<unknown>; | ||
| } | ||
| ).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.respondToPermission( | ||
| requestId, | ||
| { | ||
| outcome: { outcome: 'selected', optionId: 'allow' }, | ||
| }, | ||
| { clientId: 'client-not-issued' }, | ||
| ), | ||
| ).toThrow(InvalidClientIdError); | ||
|
|
||
| subAbort.abort(); | ||
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('respondToPermission returns false for unknown requestId', async () => { | ||
| const bridge = makeBridge({ | ||
| channelFactory: async () => makeChannel().channel, | ||
|
|
@@ -3270,6 +3339,43 @@ describe('createHttpAcpBridge', () => { | |
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('stamps model events with the trusted originator client id', async () => { | ||
| const { bridge, session } = await setup(); | ||
| const abort = new AbortController(); | ||
| const iter = bridge.subscribeEvents(session.sessionId, { | ||
| signal: abort.signal, | ||
| }); | ||
| await bridge.setSessionModel( | ||
| session.sessionId, | ||
| { | ||
| sessionId: session.sessionId, | ||
| modelId: 'qwen3-coder', | ||
| }, | ||
| { clientId: session.clientId }, | ||
| ); | ||
| const it = iter[Symbol.asyncIterator](); | ||
| const next = await it.next(); | ||
| expect(next.value?.type).toBe('model_switched'); | ||
| expect(next.value?.originatorClientId).toBe(session.clientId); | ||
| abort.abort(); | ||
| await bridge.shutdown(); | ||
| }); | ||
|
|
||
| it('rejects unregistered client ids on session-scoped requests', async () => { | ||
| const { bridge, session } = await setup(); | ||
| await expect( | ||
| bridge.setSessionModel( | ||
| session.sessionId, | ||
| { | ||
| sessionId: session.sessionId, | ||
| modelId: 'qwen3-coder', | ||
| }, | ||
| { clientId: 'client-not-issued' }, | ||
| ), | ||
| ).rejects.toBeInstanceOf(InvalidClientIdError); | ||
| await bridge.shutdown(); | ||
| }); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Add bridge-level This test covers Adding parallel tests for Reviewed by mimo-v2.5-pro |
||
|
|
||
| it('throws SessionNotFoundError for unknown session ids', async () => { | ||
| const bridge = makeBridge({ | ||
| channelFactory: async () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Bridge-level rejection tests for
cancelSessionandsendPromptwith unregistered client IDs are missing.setSessionModelandrespondToPermissionhave equivalent tests (e.g.,rejects permission votes with unregistered client ids), butcancelSessionandsendPromptdo not. The server-levelsendPrompttest uses a fake bridge that throws a hardcoded error, bypassing the realresolveTrustedClientIdlogic. Additionally,request_permission,session_update, andmodel_switch_failedevents now carryoriginatorClientIdbut are not assertion-tested for this field — onlypermission_resolvedandmodel_switchedevents are tested. Consider adding:it('rejects cancel/sendPrompt with unregistered client id')and verifyingoriginatorClientIdon all four event types.— DeepSeek/deepseek-v4-pro via Qwen Code /review