Files
claude-code/src/services/acp/__tests__/permissions.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

425 lines
12 KiB
TypeScript

import {
afterAll,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from 'bun:test'
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
import type { AssistantMessage } from '../../../types/message.js'
const askDecision = {
behavior: 'ask',
message: 'approval required',
decisionReason: { type: 'mode', mode: 'default' },
} as const
const hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
const toolInfoMock = mock(() => ({
title: 'Bash',
kind: 'execute',
content: [],
locations: [],
}))
const permissionsModuleSnapshot = {
...(require('../../../utils/permissions/permissions.ts') as Record<
string,
unknown
>),
}
const bridgeModuleSnapshot = {
...(require('../bridge.ts') as Record<string, unknown>),
}
afterAll(() => {
mock.module('../bridge.js', () => bridgeModuleSnapshot)
mock.module(
'../../../utils/permissions/permissions.js',
() => permissionsModuleSnapshot,
)
})
mock.module('../../../utils/permissions/permissions.js', () => ({
...permissionsModuleSnapshot,
hasPermissionsToUseTool: hasPermissionsMock,
}))
mock.module('../bridge.js', () => ({
...bridgeModuleSnapshot,
toolInfoFromToolUse: toolInfoMock,
}))
const { createAcpCanUseTool } = await import('../permissions.js')
type PermissionResponse =
| { outcome: { outcome: 'cancelled' } }
| { outcome: { outcome: 'selected'; optionId: string } }
function makeConn(
permissionResponse: PermissionResponse = {
outcome: { outcome: 'selected', optionId: 'allow' },
},
): AgentSideConnection {
return {
requestPermission: mock(async () => permissionResponse),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
}
function makeTool(name: string): ToolType {
return { name } as unknown as ToolType
}
const dummyContext = {} as unknown as ToolUseContext
const dummyMsg = {} as unknown as AssistantMessage
describe('createAcpCanUseTool', () => {
beforeEach(() => {
hasPermissionsMock.mockReset()
hasPermissionsMock.mockResolvedValue(askDecision)
toolInfoMock.mockClear()
})
test('returns pipeline allow without client delegation', async () => {
const conn = makeConn()
const input = { command: 'ls' }
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'allow',
updatedInput: input,
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_1',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('returns pipeline deny without client delegation', async () => {
const conn = makeConn()
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'deny',
message: 'blocked by policy',
decisionReason: { type: 'other', reason: 'blocked by policy' },
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Bash'),
{ command: 'rm -rf /' },
dummyContext,
dummyMsg,
'tu_2',
)
expect(result.behavior).toBe('deny')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('denies when the permission pipeline throws', async () => {
const conn = makeConn()
hasPermissionsMock.mockRejectedValueOnce(new Error('rule loader failed'))
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Edit'),
{ file_path: '/tmp/x' },
dummyContext,
dummyMsg,
'tu_3',
)
expect(result).toMatchObject({
behavior: 'deny',
decisionReason: { type: 'other', reason: 'Permission pipeline failed' },
toolUseID: 'tu_3',
})
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toBe('Permission pipeline failed')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
} finally {
errorSpy.mockRestore()
}
})
test('delegates ask decisions to the ACP client', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'allow' },
})
const input = { command: 'ls' }
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_4',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
const callArgs = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
expect(callArgs.sessionId).toBe('sess-1')
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe(
'tu_4',
)
})
test('returns deny when the client rejects or cancels', async () => {
const rejectConn = makeConn({
outcome: { outcome: 'selected', optionId: 'reject' },
})
const cancelConn = makeConn({ outcome: { outcome: 'cancelled' } })
const rejectResult = await createAcpCanUseTool(
rejectConn,
'sess-1',
() => 'default',
)(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_5')
const cancelResult = await createAcpCanUseTool(
cancelConn,
'sess-1',
() => 'default',
)(makeTool('Read'), {}, dummyContext, dummyMsg, 'tu_6')
expect(rejectResult.behavior).toBe('deny')
expect(cancelResult.behavior).toBe('deny')
})
test('returns deny when client permission request fails', async () => {
const conn = {
requestPermission: mock(async () => {
throw new Error('connection lost')
}),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const result = await createAcpCanUseTool(conn, 'sess-1', () => 'default')(
makeTool('Write'),
{},
dummyContext,
dummyMsg,
'tu_7',
)
expect(result.behavior).toBe('deny')
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toContain('Permission request failed')
} finally {
errorSpy.mockRestore()
}
})
test('options include allow always, allow once, reject once, and reject always', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'reject_always')).toBeTruthy()
})
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-4',
() => 'plan',
undefined,
undefined,
undefined,
() => false,
)
await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_9',
)
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
false,
)
})
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-5',
() => 'plan',
undefined,
undefined,
undefined,
() => true,
)
await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_10',
)
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
true,
)
})
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'bypassPermissions' },
})
const onModeChange = mock(() => {})
const canUseTool = createAcpCanUseTool(
conn,
'sess-6',
() => 'plan',
undefined,
undefined,
onModeChange,
() => false,
)
const result = await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_11',
)
expect(result.behavior).toBe('deny')
expect(onModeChange).not.toHaveBeenCalled()
expect(
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('checkTerminalOutput honors standard clientCapabilities.terminal', async () => {
// Standard ACP v1 client advertises terminal: true without any _meta hint.
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const capabilities = { terminal: true } as any
const canUseTool = createAcpCanUseTool(
conn,
'sess-term',
() => 'default',
capabilities,
)
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term')
const { toolCall } = (conn.requestPermission as ReturnType<typeof mock>)
.mock.calls[0][0] as Record<string, unknown>
// toolInfoFromToolUse is mocked; we only assert the standard capability is
// respected (no crash, request delegated). The legacy _meta path is
// exercised separately below.
expect(toolCall).toBeDefined()
})
test('checkTerminalOutput falls back to legacy _meta.terminal_output', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const capabilities = { _meta: { terminal_output: true } } as any
const canUseTool = createAcpCanUseTool(
conn,
'sess-term-legacy',
() => 'default',
capabilities,
)
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term2')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(1)
})
test('cancelled permission outcome invokes onPermissionCancelled callback', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const onPermissionCancelled = mock(() => {})
const canUseTool = createAcpCanUseTool(
conn,
'sess-cancel',
() => 'default',
undefined,
undefined,
undefined,
undefined,
onPermissionCancelled,
)
const result = await canUseTool(
makeTool('Bash'),
{},
dummyContext,
dummyMsg,
'tu_cancel',
)
expect(result.behavior).toBe('deny')
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
})
test('ExitPlanMode cancelled outcome invokes onPermissionCancelled callback', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const onPermissionCancelled = mock(() => {})
const canUseTool = createAcpCanUseTool(
conn,
'sess-cancel-plan',
() => 'plan',
undefined,
undefined,
undefined,
undefined,
onPermissionCancelled,
)
const result = await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_cancel_plan',
)
expect(result.behavior).toBe('deny')
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
})
})