Files
deepseek-harness/examples/headless-agent/tests/subagent-inheritance.snapshot.ts
T
Tianyi Cui cfceb8452b subagent: seed inherited policy events at creation
The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch.

Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults.

Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly.

This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
2026-07-28 21:31:17 +08:00

125 lines
6.1 KiB
TypeScript

/**
* Assembled-app regression: a parent-only read-only override is seeded into
* its child log and confines a real write under a wider deployment default.
*/
import { readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url))
const replayOverride = join(fixtureDir, 'replay.override.json')
const childReplay = join(fixtureDir, 'child.replay.jsonl')
const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
const childExpected = join(fixtureDir, 'child.expected.jsonl')
const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const sessionId = SessionId('subagent-inheritance-parent')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const task = 'Delegate the write probe to a subagent.'
/** Seed a completed parent turn with the only read-only fact in the app. */
async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const meta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
cwd,
delegationDepth: 0,
}
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
]
try {
await ctx.sessionPersistence.create(meta)
await ctx.sessionPersistence.append(sessionId, events)
} finally {
await ctx.fiber.dispose()
}
}
describe('parent-only override inheritance snapshot', () => {
it('confines a delegated child through the assembled headless app', async () => {
let cwd = ''
const result = await runLoaderSmoke({
label: 'subagent inheritance headless stream-json snapshot',
tempDirPrefix: 'dsh-subagent-inherit-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'stream-json', task],
tsconfigPath,
env: {
// The primary fixture path must exist for llm-replay's config guard;
// the override sidecar fully replaces the derived parent script.
DSH_SNAPSHOT_FILE: replayOverride,
DSH_SNAPSHOT_OVERRIDE: replayOverride,
DSH_SNAPSHOT_CHILD_FILES: childReplay,
},
prepare: async (runCwd) => {
cwd = runCwd
await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd)
},
inspect: async (runCwd) => {
// THE physical fact: the child's write never reached the disk. Under
// the deployment default (workspace-write) alone it would succeed.
await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// Collect both persisted logs (parent resumed turn + child run).
const sessionsDir = join(runCwd, '.sessions')
const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl'))
const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8')))
const headerOf = (content: string): Record<string, unknown> =>
JSON.parse(content.split('\n')[0] ?? '{}') as Record<string, unknown>
const parent = logs.find(content => content.includes('"subagent-inheritance-parent"'))
const child = logs.find(content => typeof headerOf(content).parentSession === 'string')
if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log')
const childRecords = child.trimEnd().split('\n').map(
line => JSON.parse(line) as Record<string, unknown>,
)
expect(childRecords[1]).toMatchObject({
type: 'sandbox/mode',
seq: 0,
data: { mode: 'read-only', source: 'delegation' },
})
const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd }
const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context))
const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context))
if (refreshing) {
await writeFile(parentExpected, normalizedParent)
await writeFile(childExpected, normalizedChild)
}
expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
expect(normalizedChild).toBe(await readFile(childExpected, 'utf8'))
// The child's real write was denied by the real fence.
expect(normalizedChild).toContain('file access denied under read-only mode')
},
})
expect(result.stderr).toBe('')
const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(records.at(-1)).toMatchObject({
type: 'result',
success: true,
sessionId,
result: 'The delegated child was denied by the sandbox. PARENT_DONE',
reason: { kind: 'completed' },
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})