Files
deepseek-harness/examples/acp-agent/tests/acp.snapshot.ts
Tianyi Cui a70f1a2b7a test(acp): remove vacuous policy inheritance scenario
The ACP fixture configured read-only as the deployment default for both parent and child. Its delegated write therefore remained denied even with inheritance disabled, so the scenario could not fail on the regression it claimed to protect.

Delete the overlay, scenario registration, sessions, prompt, and 473-line tool-schema sidecar. The Loader-booted headless snapshot remains the real composition guard: only its parent carries read-only while the deployment default is workspace-write, so removing inheritance makes the child write reach disk and fails the test.

Keeping one discriminating snapshot avoids 662 lines of duplicated fixture data and makes the review evidence correspond to the actual security boundary.
2026-07-28 21:32:10 +08:00

318 lines
15 KiB
TypeScript

import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { expect, it } from 'vitest'
import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
/**
* The acp-agent example's snapshot suite: the scenario table for
* `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
* (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header
* uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
* `pnpm run test:snapshot:record` re-records model transcripts against the real
* API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless.
* See the package README (packages/support/acp-snapshot) and the snapshot Agent Note,
* .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
*/
// The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and
// the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
// ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
const AGENT = {
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
}
// The Code Mode overlay configs (include-patched variants of cordis.yml; the
// replay swap resolves each one's sibling `*cordis.snapshot.yml`).
const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url))
const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url))
const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url))
const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite;
// this ACP suite should eventually retain only automation-protocol contracts.
function fixtureRecords(name: string): unknown[] {
return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8')
.trimEnd()
.split('\n')
.map(line => JSON.parse(line) as unknown)
}
function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
switch (value) {
case undefined:
case '':
case 'replay':
return 'replay'
case 'record':
return 'record'
case 'refresh':
return 'refresh'
default:
throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
}
}
const SCENARIOS: Scenario[] = [
{ name: 'handshake', hasModelTurn: false, recorded: false },
{ name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
// text-turn is the pinned-header scenario: the minimal single text turn.
// Its prompt and tool-schema sidecars pin the composed header.
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{
name: 'session-title-after-turn',
hasModelTurn: true,
recorded: false,
overridden: true,
configPath: SESSION_TITLE_CONFIG,
},
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
// Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary
// app composition. The contract below pins decoded equality and all three
// row kinds; replay additionally proves the assembled app re-packs identically.
{ name: 'packed-chunks', hasModelTurn: true, recorded: false },
// The fs overlay only adds the spill stack (the sandboxed filesystem tools
// live in the base tree), so these scenarios share the default header class.
{
name: 'parallel-tool-calls',
hasModelTurn: true,
recorded: false,
configPath: FS_CONFIG,
},
{ name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
{
name: 'session-query-spill',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'session-query',
configPath: SESSION_QUERY_CONFIG,
posixOnly: true,
},
{
name: 'pty-tools',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'pty',
configPath: PTY_CONFIG,
},
{ name: 'bash-tool-turn', hasModelTurn: true, recorded: true },
{ name: 'todo-write', hasModelTurn: true, recorded: true },
{ name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
{ name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
// web_fetch markdown rendering end to end: the overlay's loopback fixture
// server supplies deterministic HTML (entities, a GFM table, nesting), the
// REAL local fetch provider retrieves it, and the tool result pins the
// turndown conversion. The fetched URL (fixed port) is part of the recorded
// transcript; replay re-executes the real fetch against the same fixture.
{ name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG },
{
name: 'workspace-edit',
hasModelTurn: true,
recorded: true,
},
{ name: 'fs-read', hasModelTurn: true, recorded: true },
{ name: 'fs-write', hasModelTurn: true, recorded: true },
{ name: 'fs-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
{ name: 'multi-turn', hasModelTurn: true, recorded: true },
{ name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
// Keyless, authored (like error-finish): a live provider cannot be coaxed
// into a degenerate empty completion, so the fixture scripts the adapters'
// EMPTY_RESPONSE error finish in turn 1 followed by the recovered reply
// in retry turn 2, proving the default retry policy end to end: the durable
// llm/retry event, no ACP output for the discarded attempt, the recovered
// reply, and a clean completed retry turn. Its overlay only pins a deterministic
// 1 ms zero-jitter delay, so it shares the default header class.
{ name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG },
// Keyless, authored (like error-finish/cancel): deterministically forcing a
// LIVE model to repeat one call three times is not a stable recording, so
// the fixture scripts five identical todo_write calls and pins BOTH reminder
// tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
{ name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
// Authored replay: a root AGENTS.md pins the session prefix, then a read in
// nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
// injected user/message. Both AGENTS.md fixtures are symlinks to a sibling
// AGENTS.canonical.md, so this scenario also guards that discovery follows a
// symlinked instruction file to its target's content. The scenario-specific
// config keeps home/root discovery hermetic, and the resulting prefix needs
// its own pinned header class.
{
name: 'workspace-context',
hasModelTurn: true,
recorded: false,
overridden: true,
pinsHeader: true,
headerClass: 'workspace-context',
configPath: WORKSPACE_CONTEXT_CONFIG,
},
{ name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
// Cancelling a live bash call relies on POSIX process-group termination;
// Windows bash process-tree kill is deferred with the Bash execution domain.
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true },
{
name: 'subagent-depth-two-rejection',
hasModelTurn: true,
recorded: false,
overridden: true,
configPath: DEPTH_TWO_CONFIG,
},
// The workflow tool: the model writes a one-child orchestration script; the
// child runs as a spawn subagent under the worker-thread engine (its session is the
// child fixture), and the tool result carries the script's return value.
{ name: 'workflow-run', hasModelTurn: true, recorded: true },
// Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
// through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
// Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
{
name: 'advanced-toolchain',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'advanced',
configPath: ADVANCED_CONFIG,
},
{
name: 'cordis-inspect-jsdoc',
hasModelTurn: true,
recorded: false,
headerClass: 'advanced',
configPath: ADVANCED_CONFIG,
},
// Prompt-submit blocks are authored keylessly. Admission rejects before a
// turn opens, so only the ACP stop reason is observable and no log is harvested.
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
// SessionStart/SubagentStart are excluded because detached injection races log
// order; SubagentStop writes no transcript, so an expected output could not prove it ran.
// Unit tests cover those points; the hook-snapshot-matrix Agent Note owns the rationale.
{ name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true },
{ name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
{ name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
// Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
// tools:sdk section rides in the prompt, and the program's tool calls land as
// tool/code-dispatch events. Each overlay composes and pins its own header class.
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
// A nested fs dispatch inside run_code discovers workspace instructions. The
// injected user/message must follow the outer result while retaining workspace
// provenance, which proves Code Mode carries deferred tool context end to end.
{
name: 'code-mode-workspace-context',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
headerClass: 'code-workspace-context',
configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
},
{ name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG },
// Machine permission scenarios use an explicit deployment policy; there is
// no session-scoped UI picker on the automation protocol.
{
name: 'escalation-approved',
hasModelTurn: true,
recorded: true,
pinsHeader: true,
headerClass: 'sandbox',
env: { DSH_PERMISSION_MODE: 'workspace-write' },
},
{
name: 'escalation-rejected',
hasModelTurn: true,
recorded: true,
headerClass: 'sandbox',
env: { DSH_PERMISSION_MODE: 'workspace-write' },
},
{
name: 'fs-escalation-approved',
hasModelTurn: true,
recorded: true,
headerClass: 'sandbox',
env: { DSH_PERMISSION_MODE: 'workspace-write' },
},
// Unlike ordinary snapshots, this session cwd is outside the platform temp
// roots that workspace-write always grants. The overlay points the
// deployment fallback at /tmp, so a successful relative write proves the
// assembled app replaced that process-level fallback with SessionHeader.cwd.
{
name: 'session-sandbox-root',
hasModelTurn: true,
recorded: false,
overridden: true,
headerClass: 'sandbox',
configPath: SESSION_SANDBOX_ROOT_CONFIG,
env: { DSH_PERMISSION_MODE: 'workspace-write' },
workspaceParent: homedir(),
},
]
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: SNAPSHOTS_DIR,
scenarios: SCENARIOS,
mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
})
it('packed ACP fixture retains every chunk row kind without changing the logical session', () => {
const source = fixtureRecords(PACKED_CHUNKS_SOURCE)
const packed = fixtureRecords('packed-chunks')
const rowTypes = packed.flatMap((record) => {
if (record === null || typeof record !== 'object') return []
const type = (record as { type?: unknown }).type
return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : []
})
expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
const withoutMessageId = (record: unknown): unknown => {
const cloned = structuredClone(record) as {
type?: unknown
data?: { id?: unknown; message?: { id?: unknown } }
}
if (cloned.type === 'user/message') delete cloned.data?.id
if (cloned.type === 'assistant/message'
|| cloned.type === 'tool/result'
|| cloned.type === 'steering/message') {
delete cloned.data?.message?.id
}
return cloned
}
const logicalRecords = (records: readonly unknown[]): unknown[] => [
records[0],
...records.slice(1).flatMap(record => decodeStorageRecord(record)).map(withoutMessageId),
]
expect(logicalRecords(packed)).toStrictEqual(logicalRecords(source))
})