hooks.e2e.ts 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  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. let spawned: LaunchedAcpTestAgent | undefined
  25. let workdir: string | undefined
  26. afterEach(async () => {
  27. const ownedSpawned = spawned
  28. const ownedWorkdir = workdir
  29. spawned = undefined
  30. workdir = undefined
  31. await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
  32. })
  33. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
  34. it('denies every bash command, so the requested file is never written (verified on disk)', async () => {
  35. workdir = await mkdtemp(join(tmpdir(), 'acp-hooks-e2e-'))
  36. // `configPath` is process-relative, so placing the match-all hook in the
  37. // launch cwd selects it; hook commands themselves run in the session cwd.
  38. await writeFile(join(workdir, 'hooks.json'), JSON.stringify({
  39. hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] },
  40. }))
  41. spawned = launchAcpTestAgent({
  42. agent: AGENT,
  43. cwd: workdir,
  44. env: { DSH_PERMISSION_MODE: 'danger-full-access' },
  45. })
  46. const { client, updates } = spawned
  47. await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  48. const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
  49. const res = await client.prompt({
  50. sessionId,
  51. 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.' }],
  52. })
  53. // The turn completes normally (the block is a tool-result error fed back to
  54. // the model, not a turn failure).
  55. expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
  56. // Assert the denied operation independently of the model response.
  57. await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow()
  58. // ACP publishes only the committed answer; hook/tool trace stays in the session log.
  59. expect(updates.length).toBeGreaterThan(0)
  60. expect(updates.every(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
  61. }, 180_000)
  62. })