Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
session_cancel: { since: 'v1' },
session_events: { since: 'v1' },
session_set_model: { since: 'v1' },
client_identity: { since: 'v1' },
permission_vote: { since: 'v1' },
} as const satisfies Record<string, ServeCapabilityDescriptor>;

Expand Down
112 changes: 109 additions & 3 deletions packages/cli/src/serve/httpAcpBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
} from '@agentclientprotocol/sdk';
import {
createHttpAcpBridge,
InvalidClientIdError,
InvalidPermissionOptionError,
InvalidSessionScopeError,
MAX_WORKSPACE_PATH_LENGTH,
Expand Down Expand Up @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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([
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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' },
Expand All @@ -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,
Expand Down Expand Up @@ -3270,6 +3339,43 @@ describe('createHttpAcpBridge', () => {
await bridge.shutdown();

Copy link
Copy Markdown
Collaborator

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 cancelSession and sendPrompt with unregistered client IDs are missing. setSessionModel and respondToPermission have equivalent tests (e.g., rejects permission votes with unregistered client ids), but cancelSession and sendPrompt do not. The server-level sendPrompt test uses a fake bridge that throws a hardcoded error, bypassing the real resolveTrustedClientId logic. Additionally, request_permission, session_update, and model_switch_failed events now carry originatorClientId but are not assertion-tested for this field — only permission_resolved and model_switched events are tested. Consider adding: it('rejects cancel/sendPrompt with unregistered client id') and verifying originatorClientId on all four event types.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

});

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();
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add bridge-level InvalidClientIdError tests for sendPrompt and cancelSession

This test covers setSessionModel rejecting unregistered client IDs — great. However, sendPrompt and cancelSession also call resolveTrustedClientId and throw InvalidClientIdError, but lack equivalent bridge-level tests. The server-layer tests cover the prompt error-path wiring ("400 invalid_client_id when the bridge rejects prompt originator"), but not the cancel or permission-vote routes.

Adding parallel tests for sendPrompt and cancelSession rejecting { clientId: 'client-not-issued' } would complete the enforcement test matrix and guard against future regressions in these identity-validation paths.


Reviewed by mimo-v2.5-pro


it('throws SessionNotFoundError for unknown session ids', async () => {
const bridge = makeBridge({
channelFactory: async () => {
Expand Down
Loading
Loading