subagent: capture overrides at delegation; stamp ahead of prompt vetoes

Review fixes (ds-review-bot on #623):

- Capture-at-delegation: the driver now reads overrideOf(parent.session)
  for both knobs synchronously before its first await, and the prompt-submit
  listener stamps those captured values — a parent switch racing the child's
  asynchronous creation belongs to the parent's future, not the child. The
  inheritOverride(parent, child) service method is split into its two halves
  (overrideOf / stampOverride) accordingly.
- Veto safety: the one-shot prompt-submit listener registers with
  prepend: true, so a veto-capable listener (a denying UserPromptSubmit
  hook) cannot close the child's first turn without the durable stamp.

Both regressions are pinned red-first in inheritance.spec.ts: the
delegation-vs-late-switch race (delegate tool flips the caller wider while
the creation transaction is pending) and a blocking prompt-submit listener
(stamp survives a promptless first turn). Service contract tests renamed to
the split API; READMEs and the bilingual Agent Note updated.
This commit is contained in:
kingwl
2026-07-25 10:51:25 +08:00
parent 669771097d
commit 6fa2377e34
12 changed files with 192 additions and 91 deletions
@@ -143,45 +143,40 @@ describe('the sandbox/mode session kit', () => {
})
})
describe('inheritOverride (parent → child stamping)', () => {
describe('delegation inheritance (overrideOf + stampOverride)', () => {
const modeEvents = (session: Session) => session.events.filter(e => e.type === 'sandbox/mode')
it('stamps the parent LAST override onto the child through the canonical write path', async () => {
const ctx = await mounted()
it('overrideOf folds to the LAST override and never falls back to the deployment default', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const parent = session('sess-inherit-parent')
const child = session('sess-inherit-child')
setSandboxMode(parent, 'workspace-write')
setSandboxMode(parent, 'read-only')
ctx.sandboxPolicy.inheritOverride(parent, child)
expect(ctx.sandboxPolicy.overrideOf(parent)).toBe('read-only')
// undefined, NOT the deployment default — a child stamped with the
// default would stop following the LIVE default across resumes.
expect(ctx.sandboxPolicy.overrideOf(session('sess-inherit-unswitched'))).toBeUndefined()
})
it('stampOverride appends the captured mode through the canonical write path', async () => {
const ctx = await mounted()
const child = session('sess-inherit-child')
ctx.sandboxPolicy.stampOverride(child, 'read-only')
const stamped = modeEvents(child)
expect(stamped).toHaveLength(1)
expect(stamped[0]?.data).toEqual({ mode: 'read-only' })
})
it('appends NOTHING when the parent never switched (the deployment default must stay live)', async () => {
const ctx = await mounted({ mode: 'workspace-write' })
const parent = session('sess-inherit-default-parent')
const child = session('sess-inherit-default-child')
ctx.sandboxPolicy.inheritOverride(parent, child)
// No event — a resumed child keeps following whatever the deployment
// default is THEN, instead of a frozen copy of today's default.
expect(child.events).toHaveLength(0)
})
it('skips the append when the child already folds to the inherited mode (fork-seed dedup)', async () => {
it('stampOverride skips a child already folding to the mode (fork-seed dedup)', async () => {
const ctx = await mounted()
const parent = session('sess-inherit-dedup-parent')
const child = session('sess-inherit-dedup-child')
setSandboxMode(parent, 'read-only')
// A fork seed can already carry the parent's switch; stamping again would
// append a redundant event on every delegation.
setSandboxMode(child, 'read-only')
ctx.sandboxPolicy.inheritOverride(parent, child)
ctx.sandboxPolicy.stampOverride(child, 'read-only')
expect(modeEvents(child)).toHaveLength(1)
})