Files
deepseek-harness/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
Tianyi Cui 739647afc0 fix(subagent): publish inherited policy facts to telemetry
Inherited sandbox and approval events were part of the constructor seed. Session.firstLiveSeq classifies every constructor event as replayed history, so telemetry adoption skipped these child-only creation facts even though no parent or prior process had exported them.

Capture the parent overrides at the same synchronous delegation boundary, but append the events during the child factory setup while the session is still unpublished. They remain ordered after fork history, persist with the first child batch, and retain last-event-wins behavior while landing on the live side of the telemetry boundary. This uses the existing setup and session append contracts instead of adding another seed category or telemetry special case.

Add regression coverage for exporting an unpublished suffix without re-exporting constructor history, assert the spawn and fork firstLiveSeq boundaries, and restore the public seed documentation to replay/fork history only.
2026-07-29 00:39:56 +08:00

184 lines
7.3 KiB
TypeScript

/** Policy inheritance through child session events appended before publication. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
const contexts: Context[] = []
let workspace: string
beforeEach(async () => {
workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
})
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
await rm(workspace, { recursive: true, force: true })
})
async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
await ctx.plugin(ToolFs)
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(
SessionId('parent'),
{ provider: 'mock', model: 'mock' },
{ cwd: workspace },
)
return { ctx, parent }
}
function spawnRequest(parent: Agent) {
return {
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal: new AbortController().signal,
}
}
function toolResultTexts(agent: Agent): string[] {
return agent.session.events
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
.map(event => event.data.message.content
.flatMap(block => block.content)
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(''))
}
describe('in-process policy inheritance', () => {
it('records parent overrides before publishing a spawn child', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
setSandboxMode(parent.session, 'read-only')
setApprovalPolicy(parent.session, 'never')
const parentLogLength = parent.session.events.length
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
const result = await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
expect(child.session.events.slice(0, 2)).toMatchObject([
{ type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
{ type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
expect(child.session.header.seedLength).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
const request = child.session.events.find(
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
)
expect(request?.data.header.system).toContain('Approval prompts are disabled')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
}
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'fork-blocked.txt')
setSandboxMode(parent.session, 'workspace-write')
const seed = [...parent.session.events]
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), { seed })
try {
await run.result
const child = run.localAgent as Agent
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 1, data: { mode: 'read-only', source: 'delegation' } },
])
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
setSandboxMode(child.session, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
} finally {
await run.dispose()
}
})
it('captures policy at delegation before asynchronous child creation', async () => {
const script: Script = [textResponse('child done')]
const { ctx, parent } = await setupWalled(script)
setSandboxMode(parent.session, 'read-only')
const starting = startInProcessRun(spawnRequest(parent), {})
setSandboxMode(parent.session, 'danger-full-access')
const run = await starting
try {
await run.result
const child = run.localAgent as Agent
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
} finally {
await run.dispose()
}
})
it('does not freeze deployment defaults into an unswitched child', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
script.push(
toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
expect(await readFile(allowed, 'utf8')).toBe('fine')
expect(child.session.events.some(
event => event.type === 'sandbox/mode' || event.type === 'approval/policy',
)).toBe(false)
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
})