Merge remote-tracking branch 'origin/master' into goal-ui-merge-master
This commit is contained in:
@@ -150,6 +150,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async request(req: ApprovalRequest): Promise<ApprovalOutcome>',
|
||||
jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'overrideOf(session: Session): ApprovalPolicy | undefined',
|
||||
jsDoc: '/**\n * Read the session override without applying the configured default.\n * @param session - session whose log supplies the override.\n * @returns the last logged policy, or `undefined` without one.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -498,6 +502,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy',
|
||||
jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'overrideOf(session: Session): SandboxMode | undefined',
|
||||
jsDoc: '/**\n * Read the session override without applying the deployment default.\n * @param session - session whose log supplies the override.\n * @returns the last logged mode, or `undefined` without one.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,16 +63,11 @@ export interface CreateAgentOptions {
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
* primitive). When present, the factory creates the session with this event
|
||||
* prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
|
||||
* in-process FORK subagent backend to seed a child with a balanced
|
||||
* completed-turn prefix of the parent's log. The prefix MUST be contiguous
|
||||
* from seq 0, carry only lossless-JSON data, and be balanced (no open
|
||||
* turn/step, no dangling tool-call), or the session constructor (and the
|
||||
* dev-mode invariants replay) reject it. The factory passes the raw seed to
|
||||
* the session's durable validator/snapshot boundary. Absent for a fresh
|
||||
* (spawn) child.
|
||||
* Initial replay/fork history. A fork supplies a balanced completed-turn
|
||||
* prefix of the parent's log. The complete seed must be contiguous from seq
|
||||
* 0, carry only lossless-JSON data, and contain no open turn/step or dangling
|
||||
* tool call. The factory passes it to the session's durable
|
||||
* validator/snapshot boundary before publication.
|
||||
*/
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/** Per-agent options (model, …). */
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface SessionHeader {
|
||||
* store folds into a {@link SessionHeader}.
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
/** Initial replay or fork history supplied at construction. */
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Storage metadata read once before publication. `seedLength` is explicit
|
||||
|
||||
@@ -100,10 +100,19 @@ export class SandboxPolicyService extends Service {
|
||||
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
|
||||
const { session } = request
|
||||
return {
|
||||
mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode,
|
||||
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
|
||||
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session override without applying the deployment default.
|
||||
* @param session - session whose log supplies the override.
|
||||
* @returns the last logged mode, or `undefined` without one.
|
||||
*/
|
||||
overrideOf(session: Session): SandboxMode | undefined {
|
||||
return effectiveSandboxMode(session.events)
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxPolicyService
|
||||
|
||||
@@ -27,11 +27,14 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* The session's sandbox mode was switched — log-only (like `approval/*`;
|
||||
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
|
||||
* never in the model transcript. The LAST such event is the session's
|
||||
* override ({@link effectiveSandboxMode}); who asked for it is derivable
|
||||
* from position (an event after the log's last `request/header*` was a
|
||||
* runtime switch by the user; see the tool layer's narrator).
|
||||
* override ({@link effectiveSandboxMode}). `source: 'delegation'` marks
|
||||
* an override seeded into a child; an absent source is a runtime switch.
|
||||
*/
|
||||
'sandbox/mode': { mode: SandboxMode }
|
||||
'sandbox/mode': {
|
||||
mode: SandboxMode
|
||||
/** Marks an override seeded into a child at delegation. */
|
||||
source?: 'delegation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ describe('SandboxPolicyService', () => {
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolve('/projects/second'),
|
||||
})
|
||||
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
|
||||
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
|
||||
expect(ctx.sandboxPolicy.resolve()).toEqual({
|
||||
mode: 'workspace-write',
|
||||
workspaceRoot: resolve('/fallback'),
|
||||
|
||||
@@ -64,6 +64,9 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
* @returns the header, absent optional fields omitted.
|
||||
*/
|
||||
export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
if (Object.hasOwn(line, 'sandboxMode') || Object.hasOwn(line, 'approvalPolicy')) {
|
||||
throw new Error('session header uses retired policy baseline fields')
|
||||
}
|
||||
return {
|
||||
version: line.version,
|
||||
id: line.id,
|
||||
|
||||
@@ -1023,6 +1023,12 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
expect(ids).toContain('big')
|
||||
})
|
||||
|
||||
it.each(['sandboxMode', 'approvalPolicy'] as const)('rejects the retired %s header field', (field) => {
|
||||
const line = { ...toHeaderLine(meta('retired-policy-header')), [field]: 'read-only' }
|
||||
expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`)))
|
||||
.toThrow(/retired policy baseline fields/)
|
||||
})
|
||||
|
||||
it('list rejects a header whose cwd does not identify its physical log', async () => {
|
||||
const m = meta('misplaced', '/stored')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 10
|
||||
export const SCHEMA_VERSION = 12
|
||||
|
||||
/** SQLite application id protecting unrelated databases from persistence writes. */
|
||||
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
|
||||
|
||||
@@ -609,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(10)
|
||||
expect(SCHEMA_VERSION).toBe(12)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
/** Current derived-index schema version. Incompatible versions reset in place. */
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7
|
||||
|
||||
/** SQLite application id protecting unrelated databases from derived resets. */
|
||||
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
|
||||
README.md: 3606799e6d16e80473006f82b834a10953270914
|
||||
README.zh.md: 7f52d3699d1240f960e437d12bc48a152658cd15
|
||||
README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f
|
||||
README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae
|
||||
|
||||
@@ -18,6 +18,8 @@ The driver follows this sequence:
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
|
||||
|
||||
@@ -30,22 +30,36 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-sandbox-policy": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-user-approval": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReaso
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
// to the policy services when composed — the driver consumes both
|
||||
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
attachStructuredRuntime,
|
||||
type StructuredAttachment,
|
||||
@@ -97,8 +102,20 @@ export async function startInProcessRun(
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
|
||||
// Capture before the first await: a later parent switch belongs to the
|
||||
// parent's future.
|
||||
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
|
||||
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
|
||||
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
const childSession = (childCtx.agent as Agent).session
|
||||
if (inheritedMode !== undefined) {
|
||||
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
|
||||
}
|
||||
if (inheritedPolicy !== undefined) {
|
||||
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
|
||||
}
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
}
|
||||
@@ -118,7 +135,7 @@ export async function startInProcessRun(
|
||||
delegationDepth: childDepth,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
agentOptions,
|
||||
signal: request.signal,
|
||||
setup,
|
||||
|
||||
183
packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
Normal file
183
packages/subagent/subagent-inprocess/tests/inheritance.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/** 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -226,6 +226,9 @@ describe('startInProcessRun', () => {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
// The driver's synchronous inheritance capture probes both policy
|
||||
// services opportunistically; this stub composes neither.
|
||||
get: () => undefined,
|
||||
agents: {
|
||||
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
|
||||
const handle = await ctx.agents.create(options)
|
||||
|
||||
@@ -32,8 +32,14 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -168,28 +168,25 @@ describe('TelemetryCoordinator capture', () => {
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator adoption', () => {
|
||||
it('starts export at the construction boundary: seeded history never re-exports', async () => {
|
||||
it('exports an unpublished suffix without re-exporting constructor history', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'seed-parent')
|
||||
appendTurn(parent)
|
||||
const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
// The live parent (no constructor seed) replays in full; the child's
|
||||
// inherited prefix already left the process under another identity (the
|
||||
// parent's id here; the same id in a previous process for a resume) and
|
||||
// must not be re-exported — only its live suffix ships.
|
||||
const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
ctx.sessions.enter(child)
|
||||
ctx.sessions.announce(child)
|
||||
|
||||
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
|
||||
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([])
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]))
|
||||
.toEqual(expect.arrayContaining([['seeded', 2]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]])
|
||||
})
|
||||
|
||||
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
|
||||
|
||||
@@ -60,11 +60,15 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* from the prompt section and the narrator's notices). The LAST such
|
||||
* event is the session's override ({@link effectiveApprovalPolicy});
|
||||
* who asked for it is derivable from position (an event after the log's
|
||||
* last `request/header` was a runtime switch by the user).
|
||||
* event is the session's override ({@link effectiveApprovalPolicy}).
|
||||
* `source: 'delegation'` marks an override seeded into a child; an absent
|
||||
* source is a runtime switch.
|
||||
*/
|
||||
'approval/policy': { policy: ApprovalPolicy }
|
||||
'approval/policy': {
|
||||
policy: ApprovalPolicy
|
||||
/** Marks an override seeded into a child at delegation. */
|
||||
source?: 'delegation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,11 +254,13 @@ export class ApprovalService extends Service {
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
let overrideSource: 'delegation' | undefined
|
||||
let headerIndex = -1
|
||||
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
|
||||
const event = events[index] as (typeof events)[number]
|
||||
if (overrideIndex < 0 && event.type === 'approval/policy') {
|
||||
overrideIndex = index
|
||||
overrideSource = event.data.source
|
||||
} else if (headerIndex < 0 && event.type === 'request/header') {
|
||||
headerIndex = index
|
||||
}
|
||||
@@ -268,7 +274,9 @@ export class ApprovalService extends Service {
|
||||
// Cold start (nothing ever told) narrates nothing — the section about
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
const cause = overrideSource === 'delegation'
|
||||
? 'inherited from the delegating session'
|
||||
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
@@ -323,7 +331,16 @@ export class ApprovalService extends Service {
|
||||
* @returns the policy every ask for this session resolves under right now.
|
||||
*/
|
||||
private effectivePolicy(session: Session): ApprovalPolicy {
|
||||
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
|
||||
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session override without applying the configured default.
|
||||
* @param session - session whose log supplies the override.
|
||||
* @returns the last logged policy, or `undefined` without one.
|
||||
*/
|
||||
overrideOf(session: Session): ApprovalPolicy | undefined {
|
||||
return effectiveApprovalPolicy(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -456,7 +456,9 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const { agent, session } = sessionAgent('sess-gate-3')
|
||||
expect(ctx.approval.overrideOf(session)).toBeUndefined()
|
||||
setApprovalPolicy(session, 'ask')
|
||||
expect(ctx.approval.overrideOf(session)).toBe('ask')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
@@ -507,6 +509,18 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('attributes a constructor-seeded policy event to delegation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
|
||||
appendHeader(session, ASK_MARKER)
|
||||
session.append('approval/policy', { policy: 'never', source: 'delegation' })
|
||||
|
||||
await preStep(ctx, agent)
|
||||
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
|
||||
})
|
||||
|
||||
it('narrates a config default drift from the logged ask marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
|
||||
Reference in New Issue
Block a user