Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
880 changes: 880 additions & 0 deletions docs/acp-compliance-audit.md

Large diffs are not rendered by default.

281 changes: 281 additions & 0 deletions docs/acp-refactor-plan.md

Large diffs are not rendered by default.

222 changes: 222 additions & 0 deletions packages/acp-link/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ describe('permission mode resolution', () => {
{
type: 'error',
payload: {
// Legacy error envelope now carries the JSON-RPC code as a string
// (audit §8.3). -32602 = invalid params.
code: '-32602',
message: expect.stringContaining(
'bypassPermissions requires local ACP_PERMISSION_MODE',
),
Expand Down Expand Up @@ -304,3 +307,222 @@ describe('Heartbeat constants', () => {
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
})
})

describe('JSON-RPC 2.0 routing (audit §8.1-8.5)', () => {
// Helper to register a JSON-RPC-capable client and capture sent frames.
function setupJsonRpcClient(
sent: unknown[],
options: {
connection?: unknown
sessionId?: string | null
} = {},
) {
const ws = makeTestWs(sent)
process.env.ACP_LINK_TEST_INTERNALS = '1'
const unregister = __testing.registerClient(ws, {
connection: options.connection,
sessionId: options.sessionId ?? null,
jsonRpc: true,
})
return { ws, unregister }
}

test('unknown JSON-RPC method yields -32601 method-not-found (§8.4)', async () => {
const sent: unknown[] = []
const { ws, unregister } = setupJsonRpcClient(sent)
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 42,
method: 'session/nonexistent_method',
params: {},
})
// JSON-RPC clients receive a JSON-RPC error with the standard code.
expect(sent).toContainEqual({
jsonrpc: '2.0',
id: 42,
error: {
code: -32601,
message: 'Method not found: session/nonexistent_method',
},
})
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})

test('JSON-RPC response echoes the request id (§8.2)', async () => {
const sent: unknown[] = []
const prompt = mock(async () => ({ stopReason: 'end_turn' }))
const { ws, unregister } = setupJsonRpcClient(sent, {
connection: { prompt },
sessionId: 'sess-1',
})
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 'req-7',
method: 'session/prompt',
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] },
})
// The id is echoed back in the JSON-RPC result.
expect(sent).toContainEqual({
jsonrpc: '2.0',
id: 'req-7',
result: { stopReason: 'end_turn' },
})
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})

test('$/cancel_request is handled and forwards to session/cancel (§8.5)', async () => {
const sent: unknown[] = []
const cancel = mock(async () => {})
const { ws, unregister } = setupJsonRpcClient(sent, {
connection: { cancel },
sessionId: 'sess-1',
})
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 'cancel-1',
method: '$/cancel_request',
params: { id: 'req-7' },
})
// The cancel was forwarded to the ACP cancel path.
expect(cancel).toHaveBeenCalled()
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})

test('JSON-RPC notifications (no id) are dispatched without a response', async () => {
const sent: unknown[] = []
const cancel = mock(async () => {})
const { ws, unregister } = setupJsonRpcClient(sent, {
connection: { cancel },
sessionId: 'sess-1',
})
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
method: 'session/cancel',
params: {},
})
expect(cancel).toHaveBeenCalled()
// No JSON-RPC response frame should be emitted for a notification.
expect(
sent.find(m => (m as { jsonrpc?: string }).jsonrpc),
).toBeUndefined()
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})

test('session/set_mode is forwarded to the agent connection (§8.4)', async () => {
const sent: unknown[] = []
const setSessionMode = mock(async () => ({ modeId: 'plan' }))
const { ws, unregister } = setupJsonRpcClient(sent, {
connection: { setSessionMode },
sessionId: 'sess-1',
})
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 'm1',
method: 'session/set_mode',
params: { sessionId: 'sess-1', modeId: 'plan' },
})
expect(setSessionMode).toHaveBeenCalled()
// The response carries the echoed id.
expect(sent).toContainEqual({
jsonrpc: '2.0',
id: 'm1',
result: { modeId: 'plan' },
})
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})

test('session/close is forwarded to the agent connection (§8.4)', async () => {
const sent: unknown[] = []
const unstable_closeSession = mock(async () => ({}))
const { ws, unregister } = setupJsonRpcClient(sent, {
connection: { unstable_closeSession },
sessionId: 'sess-1',
})
try {
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 'c1',
method: 'session/close',
params: { sessionId: 'sess-1' },
})
expect(unstable_closeSession).toHaveBeenCalled()
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})
})

describe('Capability and protocolVersion transparency (audit §8.6, §8.7, §8.13)', () => {
test('initialize forwards client-supplied clientInfo/capabilities (§8.7)', async () => {
const sent: unknown[] = []
const ws = makeTestWs(sent)
process.env.ACP_LINK_TEST_INTERNALS = '1'
const unregister = __testing.registerClient(ws, { connection: null })
try {
// Send initialize with custom clientInfo; the proxy should remember it.
await __testing.dispatchJsonRpcMessage(ws, {
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: {
clientInfo: { name: 'my-editor', version: '2.3.4' },
clientCapabilities: { terminal: { create: true } },
},
})
// The handler invocation will fail (no agent process) but clientInfo was
// captured before the call. We verify by checking that no -32602 invalid
// params error is raised about clientInfo.
expect(sent.length).toBeGreaterThan(0)
} finally {
unregister()
delete process.env.ACP_LINK_TEST_INTERNALS
}
})
})

describe('ws-message JSON-RPC decoding (audit §8.1)', () => {
test('decodeJsonWsMessage accepts JSON-RPC 2.0 requests', async () => {
const { decodeJsonWsMessage, isJsonRpc2Message } = await import(
'../ws-message.js'
)
const msg = decodeJsonWsMessage(
'{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{}}',
)
expect(isJsonRpc2Message(msg)).toBe(true)
expect((msg as { method?: string }).method).toBe('session/prompt')
})

test('decodeJsonWsMessage still accepts legacy {type,payload} envelope', async () => {
const { decodeJsonWsMessage } = await import('../ws-message.js')
const msg = decodeJsonWsMessage('{"type":"ping"}')
expect((msg as { type?: string }).type).toBe('ping')
})

test('decodeJsonWsMessage rejects non-JSON-RPC, non-type payloads', async () => {
const { decodeJsonWsMessage } = await import('../ws-message.js')
expect(() => decodeJsonWsMessage('{"foo":"bar"}')).toThrow(
'Invalid WebSocket message payload',
)
})
})
7 changes: 5 additions & 2 deletions packages/acp-link/src/rcs-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,12 @@ export class RcsUpstreamClient {
} else if (data.type === 'keep_alive') {
// ignore keepalive
} else {
// Forward ACP protocol messages to handler (for RCS relay support)
// Forward ACP protocol messages to handler (for RCS relay support).
// This branch handles both the legacy `{type, payload}` envelope
// and JSON-RPC 2.0 messages (which have no `type` field) so the
// relay preserves the JSON-RPC format end-to-end (audit §8.12).
RcsUpstreamClient.log.debug(
{ type: data.type },
{ type: data.type, method: data.method },
'forwarding to relay handler',
)
this.messageHandler?.(data)
Expand Down
Loading