hooks.e2e.ts 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. import { mkdtemp, writeFile, access } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  7. import {
  8. launchAcpTestAgent,
  9. type AgentUnderTest,
  10. type LaunchedAcpTestAgent,
  11. } from '@deepseek-ai/dsh-acp-snapshot'
  12. import { cleanupAcpExampleTest } from './cleanup.ts'
  13. /**
  14. * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is
  15. * resolved from a temporary launch cwd and blocks all PreToolUse calls; a real
  16. * model is asked to write there, and absence of the file proves interception.
  17. * The test owns and disposes the ACP subprocess.
  18. */
  19. const AGENT: AgentUnderTest = {
  20. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  21. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  22. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  23. }
  24. const STANDARD_EXECUTION_UPDATES = new Set([
  25. 'agent_message_chunk',
  26. 'agent_thought_chunk',
  27. 'tool_call',
  28. 'tool_call_update',
  29. 'usage_update',
  30. ])
  31. let spawned: LaunchedAcpTestAgent | undefined
  32. let workdir: string | undefined
  33. afterEach(async () => {
  34. const ownedSpawned = spawned
  35. const ownedWorkdir = workdir
  36. spawned = undefined
  37. workdir = undefined
  38. await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
  39. })
  40. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
  41. it('denies every bash command, so the requested file is never written (verified on disk)', async () => {
  42. workdir = await mkdtemp(join(tmpdir(), 'acp-hooks-e2e-'))
  43. // `configPath` is process-relative, so placing the match-all hook in the
  44. // launch cwd selects it; hook commands themselves run in the session cwd.
  45. await writeFile(join(workdir, 'hooks.json'), JSON.stringify({
  46. hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] },
  47. }))
  48. spawned = launchAcpTestAgent({
  49. agent: AGENT,
  50. cwd: workdir,
  51. env: { DSH_PERMISSION_MODE: 'danger-full-access' },
  52. })
  53. const { client, updates } = spawned
  54. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  55. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  56. const res = await client.prompt({
  57. sessionId,
  58. prompt: [{ type: 'text', text: 'Use the bash tool to write the exact text HOOK_FAIL into a file named proof.txt in the current directory. Then stop.' }],
  59. })
  60. // The turn completes normally (the block is a tool-result error fed back to
  61. // the model, not a turn failure).
  62. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  63. // Assert the denied operation independently of the model response.
  64. await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow()
  65. // ACP publishes committed semantic execution facts, never hook internals or UI projections.
  66. expect(updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
  67. expect(updates.every(update => STANDARD_EXECUTION_UPDATES.has(update.sessionUpdate))).toBe(true)
  68. }, 180_000)
  69. })