mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
fix: 严格对齐 ACP 协议实现到 stable v1 规范
对照 /Users/konghayao/code/knowledgebase/origin/acp 规范审计并修复 53 条合规性
发现(critical 5 / major 17 / minor 20 / nit 11),完整审计报告见
docs/acp-compliance-audit.md。
Agent 端 (src/services/acp/agent.ts):
- initialize() 补齐 authMethods,promptCapabilities.image 降级为 false(声明与
实现脱节,按 initialization.mdx 不声明的 capability 视为不支持)
- sessionCapabilities.fork 移至 _meta.claudeCode.forkSession(fork 在
meta.unstable.json 中,避免在 stable sessionCapabilities 中暴露 unstable 特性)
- unstable_resumeSession 传 replay:false,不再通过 session/update 重放历史
(session-setup.mdx:239 明确禁止)
- PromptResponse.usage 移至 _meta.claudeCode.usage
(extensibility.mdx:39 禁止在 spec 类型根添加自定义字段)
- 空字符串 prompt 改为显式 throw(不再误返 end_turn)
Bridge (src/services/acp/bridge.ts):
- 删除全部 usage_update discriminator(不在 stable v1 schema 中)
- 显式映射 refusal stop_reason(之前误报 end_turn)
- max_tokens / isError 检查互斥
- Read/Write/Edit/Glob 路径全部绝对化(协议规定路径 MUST 绝对)
- 补全 resource_link / resource ContentBlock 渲染
Permissions (src/services/acp/permissions.ts):
- 补齐 reject_always PermissionOption(schema 规定的四个 option 之一)
- checkTerminalOutput 优先检查标准 clientCapabilities.terminal,
回退到 _meta.terminal_output
- 新增 onPermissionCancelled 回调:cancelled permission outcome →
StopReason::Cancelled(schema.json:629)
- ExitPlanMode cancelled 分支补上 toolUseID 字段
PromptConversion (src/services/acp/promptConversion.ts):
- resource 分支处理 BlobResource(之前静默丢弃 blob 内容)
acp-link 代理 (packages/acp-link/src/):
- WS 协议从专有 {type, payload} 改造为标准 JSON-RPC 2.0
(transports.mdx:52 要求自定义 transport MUST 保留 JSON-RPC 消息格式),
同时向后兼容旧 envelope
- 实现 $/cancel_request 处理
- 使用 JSON-RPC 标准错误码 -32700 / -32600 / -32601 / -32602 / -32603
- capability / agentInfo / protocolVersion 完整透传
验证:bun run precheck 全部通过(tsc 零错误、biome ci 零警告、5841/5841 测试通过);
ACP 专项测试 221/221 通过。独立 verification agent 抽查全部 PASS。
已知暂缓项(审计文档附录 B/C):
- §3.5 traceparent/trace-context 传播(QueryEngine 无 header hook)
- §5.2 terminal/create 完整生命周期(P1,非阻断,需新 RPC 流程)
- §4.2 in_progress tool_call status(SHOULD 级)
- §8.8/8.9/8.14 stale types.ts(不在 owner 分配集合,runtime 已修正)
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -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',
|
||||
),
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user