Files
claude-code/packages/acp-link/src/__tests__/server.test.ts
claude-code-best 5e30697950 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>
2026-06-20 12:38:43 +08:00

529 lines
16 KiB
TypeScript

import { describe, test, expect, mock } from 'bun:test'
import {
__testing,
decodeClientWsMessage,
MAX_CLIENT_WS_PAYLOAD_BYTES,
resolveNewSessionPermissionMode,
type ServerConfig,
} from '../server.js'
import {
authTokensEqual,
decodeWebSocketAuthProtocol,
encodeWebSocketAuthProtocol,
extractWebSocketAuthToken,
} from '../ws-auth.js'
import { buildRcsWsUrl } from '../rcs-upstream.js'
function makeTestWs(sent: unknown[]) {
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0]
return {
readyState: 1,
send: mock((message: string) => {
sent.push(JSON.parse(message))
}),
close: mock(() => {}),
raw: null,
isInner: false,
url: '',
origin: '',
protocol: '',
} as unknown as TestWs
}
describe('Server HTTP endpoints', () => {
test('package.json has correct bin and main entries', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } })
expect(pkg.default.name).toBe('acp-link')
expect(pkg.default.main).toBe('./dist/server.js')
expect(pkg.default.bin).toBeDefined()
expect(pkg.default.bin['acp-link']).toBe('dist/cli/bin.js')
})
test('ServerConfig interface accepts all expected fields', () => {
const config: ServerConfig = {
port: 9315,
host: 'localhost',
command: 'echo',
args: [],
cwd: '/tmp',
debug: false,
token: 'test-token',
https: false,
}
expect(config.port).toBe(9315)
expect(config.token).toBe('test-token')
})
test('ServerConfig allows optional fields to be omitted', () => {
const config: ServerConfig = {
port: 9315,
host: 'localhost',
command: 'echo',
args: [],
cwd: '/tmp',
}
expect(config.debug).toBeUndefined()
expect(config.token).toBeUndefined()
expect(config.https).toBeUndefined()
})
})
describe('WebSocket message types', () => {
const clientMessageTypes = [
'connect',
'disconnect',
'new_session',
'prompt',
'permission_response',
'cancel',
'set_session_model',
'list_sessions',
'load_session',
'resume_session',
'ping',
]
test('all client message types are recognized', () => {
expect(clientMessageTypes.length).toBe(11)
expect(clientMessageTypes).toContain('ping')
expect(clientMessageTypes).toContain('connect')
expect(clientMessageTypes).toContain('cancel')
})
test('decodes supported client message payloads', () => {
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: 'ping' })
expect(
decodeClientWsMessage(
Buffer.from('{"type":"prompt","payload":{"content":[]}}'),
),
).toEqual({ type: 'prompt', payload: { content: [] } })
expect(
decodeClientWsMessage(
new TextEncoder().encode('{"type":"cancel"}').buffer,
),
).toEqual({ type: 'cancel' })
expect(
decodeClientWsMessage([
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
Buffer.from('next"}}'),
]),
).toEqual({
type: 'list_sessions',
payload: { cwd: undefined, cursor: 'next' },
})
})
test('rejects malformed typed client payloads', () => {
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
'Invalid prompt payload',
)
expect(() =>
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
).toThrow('Invalid load_session payload')
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
'Unknown message type',
)
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":123}}',
),
).toThrow('Invalid new_session.permissionMode')
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":{}}}',
),
).toThrow('Invalid new_session.permissionMode')
expect(() =>
decodeClientWsMessage(
'{"type":"new_session","payload":{"permissionMode":null}}',
),
).toThrow('Invalid new_session.permissionMode')
})
test('rejects oversized client message payloads before decoding', () => {
const payload = 'x'.repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1)
expect(() => decodeClientWsMessage(payload)).toThrow(
'WebSocket message too large',
)
})
})
describe('WebSocket auth protocol', () => {
test('round-trips tokens through a WebSocket subprotocol token', () => {
const protocol = encodeWebSocketAuthProtocol('secret/token+with=symbols')
expect(protocol).toStartWith('rcs.auth.')
expect(protocol).not.toContain('secret/token')
expect(decodeWebSocketAuthProtocol(protocol)).toBe(
'secret/token+with=symbols',
)
})
test('ignores query-token style inputs', () => {
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined()
expect(decodeWebSocketAuthProtocol('token=secret')).toBeUndefined()
expect(decodeWebSocketAuthProtocol('other, rcs.auth.')).toBeUndefined()
})
test('prefers Authorization headers and supports protocol auth', () => {
expect(
extractWebSocketAuthToken({
authorization: 'Bearer header-token',
protocol: encodeWebSocketAuthProtocol('protocol-token'),
}),
).toBe('header-token')
expect(
extractWebSocketAuthToken({
protocol: encodeWebSocketAuthProtocol('protocol-token'),
}),
).toBe('protocol-token')
})
test('compares auth tokens through the shared constant-time path', () => {
expect(authTokensEqual('secret-token', 'secret-token')).toBe(true)
expect(authTokensEqual('secret-token', 'wrong-token')).toBe(false)
expect(authTokensEqual(undefined, 'secret-token')).toBe(false)
})
})
describe('RCS upstream URL normalization', () => {
test('removes legacy token query params from WebSocket URLs', () => {
expect(
buildRcsWsUrl('http://example.test/acp/ws?token=old-secret&x=1'),
).toBe('ws://example.test/acp/ws?x=1')
})
test('adds /acp/ws for base URLs', () => {
expect(buildRcsWsUrl('https://example.test/')).toBe(
'wss://example.test/acp/ws',
)
})
})
describe('permission mode resolution', () => {
test('uses client requested non-bypass modes', () => {
expect(resolveNewSessionPermissionMode('plan', 'acceptEdits')).toBe('plan')
})
test('uses local default when client does not request a mode', () => {
expect(resolveNewSessionPermissionMode(undefined, 'acceptEdits')).toBe(
'acceptEdits',
)
})
test('rejects client requested bypassPermissions without local default', () => {
expect(() =>
resolveNewSessionPermissionMode('bypassPermissions', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode('bypass', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode('bypasspermissions', 'acceptEdits'),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
expect(() =>
resolveNewSessionPermissionMode('bypassPermissions', undefined),
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
})
test('rejects unknown client permission modes before forwarding', () => {
expect(() =>
resolveNewSessionPermissionMode('unknown-mode', 'acceptEdits'),
).toThrow('Invalid permissionMode: unknown-mode')
})
test('allows bypassPermissions when local default already enables it', () => {
expect(
resolveNewSessionPermissionMode('bypassPermissions', 'bypassPermissions'),
).toBe('bypassPermissions')
expect(resolveNewSessionPermissionMode('bypass', 'bypassPermissions')).toBe(
'bypassPermissions',
)
expect(resolveNewSessionPermissionMode('bypassPermissions', 'bypass')).toBe(
'bypassPermissions',
)
})
test('new_session rejects client bypass before forwarding to the agent', async () => {
const sent: unknown[] = []
const ws = makeTestWs(sent)
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS
process.env.ACP_LINK_TEST_INTERNALS = '1'
let unregisterClient = () => {}
let restoreMode = () => {}
try {
const newSession = mock(async () => ({
sessionId: 'should-not-be-created',
}))
unregisterClient = __testing.registerClient(ws, {
connection: { newSession },
})
restoreMode = __testing.setDefaultPermissionMode('acceptEdits')
await __testing.dispatchClientMessage(ws, {
type: 'new_session',
payload: {
cwd: '/tmp',
permissionMode: 'bypass',
},
})
expect(newSession).not.toHaveBeenCalled()
expect(__testing.getClientSessionId(ws)).toBeNull()
expect(sent).toEqual([
{
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',
),
},
},
])
} finally {
restoreMode()
unregisterClient()
if (originalTestInternals === undefined) {
delete process.env.ACP_LINK_TEST_INTERNALS
} else {
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals
}
}
})
})
describe('Heartbeat constants', () => {
test('PERMISSION_TIMEOUT_MS is 5 minutes', () => {
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000
expect(PERMISSION_TIMEOUT_MS).toBe(300_000)
})
test('HEARTBEAT_INTERVAL_MS is 30 seconds', () => {
const HEARTBEAT_INTERVAL_MS = 30_000
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',
)
})
})