Merge remote-tracking branch 'origin/master' into worktree/pr2177-export-fixes-20260811
# Conflicts: # docs/config-catalog.i18n.yaml
This commit is contained in:
@@ -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/bash/pwsh-sandbox/README.md
|
||||
README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2
|
||||
README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec
|
||||
README.md: c47409c122ffce264f53fc41c786da015fdaab6b
|
||||
README.zh.md: 5b14185a71943d0a0a50f0bae353a3e22d0f1fb2
|
||||
|
||||
@@ -30,5 +30,5 @@ None directly; the denial surface belongs to the tool layer.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap.
|
||||
- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package).
|
||||
- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning.
|
||||
- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL.
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。
|
||||
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。
|
||||
- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。
|
||||
- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
|
||||
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
|
||||
* REAL pwsh spawns confined through the runner — the debug-instance
|
||||
* verification of both modes: read-only denies every write (not even NUL),
|
||||
* workspace-write allows the workspace and temp while denying escape writes,
|
||||
* and denial/classification facts ride the settled result.
|
||||
* verification of both modes on ordinary user-owned paths: read-only denies
|
||||
* writes, workspace-write allows its promised roots while denying escape
|
||||
* writes, and the partial-enforcement/denial facts ride the settled result.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -29,21 +29,19 @@ function pwshAvailable(): boolean {
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let outsideTempDir!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let executor!: SandboxPwshExecutor
|
||||
|
||||
beforeAll(async () => {
|
||||
// The escape probe must live OUTSIDE every legitimately granted tree: the
|
||||
// provider's workspace-write grants the workspace plus the REAL temp dir
|
||||
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
|
||||
// scratch dir under temp would inherit the grant and the probe would be a
|
||||
// false pass. A mkdtemp under the profile is removed by afterAll.
|
||||
// The workspace escape sits under the profile. A separate directory under
|
||||
// the ambient temp root proves that the root itself is not granted: the
|
||||
// runner creates its own private child and rewrites TMP/TEMP to it.
|
||||
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
|
||||
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
@@ -58,15 +56,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
rmSync(outsideTempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => {
|
||||
it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
@@ -78,7 +76,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
|
||||
// A self-caught denial keeps the command exit 0: no denial fact.
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
|
||||
// A raw failing write must classify as a denial of the ACL dialect.
|
||||
const denied = await executor.run(executor.resolve({
|
||||
@@ -86,26 +84,34 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
sandboxPolicy: policy,
|
||||
}))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
|
||||
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
|
||||
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
"try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
"'TEMP-PATH: ' + $env:TEMP",
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
|
||||
expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
|
||||
expect(privateTemp).toBeDefined()
|
||||
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
|
||||
expect(existsSync(privateTemp ?? '')).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -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/bash/tool-pwsh/README.md
|
||||
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
|
||||
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90
|
||||
README.md: 4126e718c569f17fb8be465351b2576970e93c63
|
||||
README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7
|
||||
|
||||
@@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.
|
||||
|
||||
@@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。
|
||||
|
||||
@@ -114,18 +114,18 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
if (escalationModes.length === 0) return base
|
||||
// The CLM and named-pipe contracts below are Windows-restricted-token
|
||||
// The language-mode and named-pipe contracts below are Windows-restricted-token
|
||||
// behavior, but the gate is 'any confining executor is mounted'
|
||||
// (escalationModes non-empty). The conflation is safe today because every
|
||||
// shipped composition pairing tool-pwsh with a confining executor is
|
||||
// win32-only; a future POSIX pwsh-sandbox composition must gate both
|
||||
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
|
||||
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while '
|
||||
+ 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
|
||||
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
|
||||
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'In both confined modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
|
||||
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
|
||||
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
|
||||
|
||||
@@ -559,7 +559,8 @@ describe('sandbox escalation through ctx.approval', () => {
|
||||
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.description).toContain('approval prompt')
|
||||
expect(schema.description).toContain('ConstrainedLanguage')
|
||||
expect(schema.description).toContain('named pipes')
|
||||
expect(schema.description).toContain('workspace-write stays in FullLanguage')
|
||||
expect(schema.description).toContain('In both confined modes, programs cannot open named pipes')
|
||||
expect(schema.description).toContain('fails with EPERM')
|
||||
|
||||
for (const args of [
|
||||
|
||||
@@ -311,12 +311,16 @@
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
# Fork stays one-shot: a continuable child's `report` tool and prompt
|
||||
# section precede the inherited history a fork exists to reuse; one-shot
|
||||
# fork children install neither, keeping the parent's request prefix.
|
||||
# See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
backgroundMode: one-shot
|
||||
|
||||
# Optional direct-child return channel; absent from roots and one-shot agents.
|
||||
- id: tool-subagent-report
|
||||
|
||||
@@ -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/core/agent/README.md
|
||||
README.md: 0fb65b94a3b311aa9f0df09d39dd937cba4cc7b4
|
||||
README.zh.md: 44f67483343a98c280317793ece544bd0b984596
|
||||
README.md: 61294df5cca1b03f9f158678f2992a6e4fbaaffd
|
||||
README.zh.md: 5576a07330926cfd8fd5bd42c8a0351a375f035c
|
||||
|
||||
@@ -58,6 +58,8 @@ Inbox live notifications are deliberately per-message and minimal: `agent/inbox/
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
`foldConsumedWork(events)` reads that feed back for the one question the turn sequence cannot answer alone: what became of the work a log consumed. It returns the latest `turn/end` that accounts for consumed work — a turn that entered a model step, or one that claimed inbox input and then failed, was stopped, or was rejected before reaching one — plus whether accepted work was later cancelled out of the inbox unrun. Both facts come from the log, so a cancellation reads the same whichever owner issued it. A no-step turn that took nothing, or emptied its claim and completed, describes no work and is skipped; a `blocked` end over claimed input is an account, because rejection discarded that input.
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
@@ -58,6 +58,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
|
||||
|
||||
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
|
||||
|
||||
`foldConsumedWork(events)` 把这条事件流读回来,回答仅凭轮次序列无法回答的那个问题:一份日志消费掉的工作最终怎样了。它返回能够为已消费工作作出交代的最新 `turn/end`——即进入过模型 step 的轮次,或者认领了 inbox 输入、但在进入 step 之前失败、被停下或被拒绝的轮次——并额外给出「已接受的工作此后是否被从 inbox 中取消且从未运行」。两项事实都来自日志,因此无论由哪个所有者发起取消,读出来都一样。没有取走任何输入、或认领批次被改写清空后正常结束的无 step 轮次不描述工作,会被跳过;认领过输入、以 `blocked` 结束的轮次则是一份交代,因为拒绝把这些输入一并丢弃了。
|
||||
|
||||
### Agent 接口(`types.ts`)
|
||||
|
||||
每个插件面向的 handle:
|
||||
|
||||
108
packages/core/agent/src/consumed-work.ts
Normal file
108
packages/core/agent/src/consumed-work.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* How one agent log accounts for the work it consumed.
|
||||
*
|
||||
* The turn and step vocabulary alone cannot answer this. A turn that stops
|
||||
* before its first step leaves a `turn/end` shaped exactly like the balanced
|
||||
* no-op turns a rejection or an empty claim produces, so reading turns in
|
||||
* isolation either credits cut-short work as finished or convicts every no-op.
|
||||
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
|
||||
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
|
||||
* separates a turn claiming its input from work being dropped unrun.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/consumed-work
|
||||
*/
|
||||
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** How one agent log accounts for the work it consumed. */
|
||||
export interface ConsumedWork {
|
||||
/**
|
||||
* The latest closed turn that accounts for consumed work: one that entered a
|
||||
* model step, or one that claimed inbox input and then failed, was stopped,
|
||||
* or was rejected. Absent when no turn closed over any work.
|
||||
*/
|
||||
readonly end?: SessionEvent<'turn/end'>
|
||||
/**
|
||||
* Whether accepted work was cancelled out of the inbox, unrun, after that
|
||||
* turn. This is the only account of input a cancellation took before any turn
|
||||
* could open over it — no `turn/end` describes it.
|
||||
*/
|
||||
readonly droppedUnrun: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a turn that consumed input but never reached a step ends in a way
|
||||
* that accounts for that input. Only a `completed` end does not: it had
|
||||
* nothing left to run once its claim was rewritten away. A `blocked` end is
|
||||
* that input's ending too — the pre-step rejection that produced it discarded
|
||||
* the claimed messages, so the work it took will never run.
|
||||
* @param reason - the turn's recorded ending.
|
||||
* @returns whether the ending accounts for the input the turn took.
|
||||
*/
|
||||
function accountsForClaim(reason: TurnEndReason): boolean {
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return false
|
||||
case 'blocked':
|
||||
case 'aborted':
|
||||
case 'interrupted':
|
||||
case 'error':
|
||||
return true
|
||||
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
|
||||
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
|
||||
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
|
||||
* consumed input must not read as success. */
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one agent log, or an owned suffix of one, into its account of consumed
|
||||
* work. Single pass, and every input is the log itself: no caller has to sample
|
||||
* live state before cancelling, so a cancellation issued by anyone — the owner's
|
||||
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
|
||||
* @param events - the log, or an owned suffix, to fold.
|
||||
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
|
||||
*/
|
||||
export function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork {
|
||||
const stepped = new Set<number>()
|
||||
const claimed = new Set<number>()
|
||||
let open: number | undefined
|
||||
let end: SessionEvent<'turn/end'> | undefined
|
||||
let droppedUnrun = false
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
open = event.data.turn
|
||||
break
|
||||
case 'step/start':
|
||||
stepped.add(event.data.turn)
|
||||
break
|
||||
case 'agent/inbox/spliced': {
|
||||
const { removedCount, outcome, inserted } = event.data
|
||||
if (removedCount === undefined) break
|
||||
// A replacement keeps the work pending under a new identity, so only a
|
||||
// cancellation that leaves nothing behind drops it.
|
||||
if (outcome === 'canceled') droppedUnrun ||= inserted.length === 0
|
||||
// Claims are the loop's own step-boundary reads, always inside a turn.
|
||||
else if (open !== undefined) claimed.add(open)
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
const { turn, reason } = event.data
|
||||
open = undefined
|
||||
if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
|
||||
end = event
|
||||
// Anything dropped before this turn closed is what its own ending
|
||||
// reports; only a later drop is still unaccounted for.
|
||||
droppedUnrun = false
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return { ...end === undefined ? {} : { end }, droppedUnrun }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts'
|
||||
export * from './runtime-types.ts'
|
||||
export * from './types.ts'
|
||||
export * from './inbox.ts'
|
||||
export * from './consumed-work.ts'
|
||||
export * from './model-selection.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
160
packages/core/agent/tests/consumed-work.spec.ts
Normal file
160
packages/core/agent/tests/consumed-work.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** One pending message, as the inbox records it. */
|
||||
function message(text: string) {
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/** Log an accepted message the way `Inbox.append()` does. */
|
||||
function accept(session: Session, text: string): void {
|
||||
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] })
|
||||
}
|
||||
|
||||
/** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */
|
||||
function claim(session: Session): void {
|
||||
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
|
||||
}
|
||||
|
||||
/** Log a cancellation of one pending message, as `Inbox.clear()` does. */
|
||||
function cancelPending(session: Session): void {
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
})
|
||||
}
|
||||
|
||||
/** Run one whole turn that reached a model step. */
|
||||
function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void {
|
||||
session.append('turn/start', { turn })
|
||||
claim(session)
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
describe('foldConsumedWork', () => {
|
||||
it('reports nothing for a log that consumed no work', () => {
|
||||
const session = Session.create(SessionId('empty'))
|
||||
accept(session, 'queued')
|
||||
|
||||
expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false })
|
||||
})
|
||||
|
||||
it('reports the latest turn that entered a model step', () => {
|
||||
const session = Session.create(SessionId('stepped'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
steppedTurn(session, 2, { kind: 'max-tokens' })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data)
|
||||
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
|
||||
})
|
||||
|
||||
it('reports a turn that claimed its input and then failed before any step', () => {
|
||||
const session = Session.create(SessionId('failed-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
// The step boundary runs the durability checkpoint and prompt assembly, so a
|
||||
// turn can take its input and then fail without entering a step.
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('reports a turn that claimed its input and was then stopped before any step', () => {
|
||||
const session = Session.create(SessionId('stopped-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores a turn stopped, failed, or rejected without taking any input', () => {
|
||||
const session = Session.create(SessionId('no-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } })
|
||||
session.append('turn/start', { turn: 3 })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } })
|
||||
|
||||
// None of these turns describes work: they opened, found nothing of their own, and closed.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a turn whose claimed input a pre-step rejection discarded', () => {
|
||||
const session = Session.create(SessionId('rejected-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } })
|
||||
|
||||
// Rejection does not retain the claimed messages, so the `blocked` end is
|
||||
// the only account of input that will never run.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores a claim its own turn emptied', () => {
|
||||
const session = Session.create(SessionId('emptied-claim'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
// An emptied claim ran nothing and dropped nothing: a listener rewrote the
|
||||
// batch away, which is not this log's account of the work.
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('credits a claim with no open turn to no turn at all', () => {
|
||||
const session = Session.create(SessionId('mid-turn-suffix'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
// An owned suffix can begin inside a turn whose start it does not contain,
|
||||
// so a claim may appear with no turn to attribute it to.
|
||||
claim(session)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
|
||||
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
|
||||
})
|
||||
|
||||
it('reports work cancelled out of the inbox after the last accounting turn', () => {
|
||||
const session = Session.create(SessionId('dropped'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
accept(session, 'never runs')
|
||||
cancelPending(session)
|
||||
|
||||
// No turn opened over it, so only the cancellation says the work was cut short.
|
||||
expect(foldConsumedWork(session.events)).toEqual({
|
||||
end: session.events.find(event => event.type === 'turn/end'),
|
||||
droppedUnrun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a replacement pending rather than counting it as dropped', () => {
|
||||
const session = Session.create(SessionId('replaced'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled',
|
||||
})
|
||||
|
||||
expect(foldConsumedWork(session.events).droppedUnrun).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a later accounting turn absorb an earlier drop', () => {
|
||||
const session = Session.create(SessionId('absorbed'))
|
||||
steppedTurn(session, 1, { kind: 'completed' })
|
||||
cancelPending(session)
|
||||
steppedTurn(session, 2, { kind: 'completed' })
|
||||
|
||||
expect(foldConsumedWork(session.events)).toEqual({
|
||||
end: session.events.findLast(event => event.type === 'turn/end'),
|
||||
droppedUnrun: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -34,27 +34,6 @@ export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSur
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed turn that entered at least one model step, ignoring
|
||||
* balanced no-step turns produced by rejection, empty input, or cancellation.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const steppedTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'step/start') {
|
||||
steppedTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
|
||||
@@ -7,28 +7,11 @@ import SessionStore, {
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
findLastMessageTurnEnd,
|
||||
snapshotSessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('finds the latest closed turn that entered a model step', () => {
|
||||
const session = Session.create(SessionId('last-message-turn'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)?.data)
|
||||
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
|
||||
})
|
||||
|
||||
it('exposes one stable readonly surface view', () => {
|
||||
const session = Session.create(SessionId('surface-view'))
|
||||
const surface = session.surface
|
||||
|
||||
@@ -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/sandbox/sandbox-local/README.md
|
||||
README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc
|
||||
README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3
|
||||
README.md: e43133c7c5b64d7779162b790ee6cab7806fd100
|
||||
README.zh.md: 32743d1b0aba5bed41ee53d90c4ed44dc936161c
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly.
|
||||
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt; Windows uses the ACL restricted-token runner. Multiple candidates are probed in order, while a sole candidate is selected directly.
|
||||
|
||||
The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal.
|
||||
|
||||
@@ -12,6 +12,8 @@ Policy is per call; the provider stores only the mechanism and cached runner ver
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child.
|
||||
|
||||
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.
|
||||
|
||||
```yaml
|
||||
@@ -31,7 +33,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred.
|
||||
- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
|
||||
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
|
||||
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
|
||||
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。
|
||||
[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL 受限令牌 runner。多个候选项会按顺序探测,只有一个候选项时则直接选择。
|
||||
|
||||
包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
|
||||
|
||||
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。如果工作区等于或包含平台临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的工作区 ACE 会延伸到每个私有临时子目录。
|
||||
|
||||
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。
|
||||
|
||||
```yaml
|
||||
@@ -31,7 +33,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Windows 没有 runner**:`win32` 以 `SANDBOX_UNAVAILABLE` 拒绝执行;AppContainer 家族后端暂缓实现。
|
||||
- **Windows ACL 只能实现部分强制执行**:受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
|
||||
- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
|
||||
- **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
|
||||
- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。
|
||||
|
||||
@@ -7,20 +7,21 @@
|
||||
*
|
||||
* The windows-acl rung additionally owns the write grants: the write SID is
|
||||
* the per-WORKSPACE identity derived from the canonical workspace path
|
||||
* (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per
|
||||
* session (session id + workspace — nothing stored). The
|
||||
* (`workspaceWriteSid`), while every live session receives a RANDOM private
|
||||
* temp directory and its own derived capability (`tempWriteSid`). The
|
||||
* workspace-root ACE materializes once per workspace per server lifetime
|
||||
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
|
||||
* every later provision O(1) instead of re-propagating the tree per
|
||||
* session); the private-temp ACEs are revoked on dispose. The runner
|
||||
* receives `--write-sid` (the derived identity; its presence marks the
|
||||
* seam-managed contract) and stops managing DACLs itself.
|
||||
* receives both SIDs (their presence marks the seam-managed contract) and
|
||||
* stops managing DACLs itself. The rung reports partial enforcement because
|
||||
* WRITE_RESTRICTED must retain Everyone in its
|
||||
* restricting list and NTFS hard links alias one file object across paths.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -36,7 +37,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -110,25 +111,6 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number):
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's private temp subdirectory: `<tmpdir>\dsh-<16 hex>`, derived
|
||||
* from the session id and its workspace instead of stored. The same session
|
||||
* and workspace always name the same directory — a resumed session
|
||||
* re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's
|
||||
* different session id names a fresh one. The name is predictable to anyone
|
||||
* who knows the session id (the confined command sees it as
|
||||
* `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and
|
||||
* rejects reparse points: a pre-placed entry fails the first confined run
|
||||
* loudly, and cannot redirect the grant onto a foreign object.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @returns the session's private temp subdirectory path.
|
||||
*/
|
||||
export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string {
|
||||
const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex')
|
||||
return join(tmpdir(), `dsh-${digest.slice(0, 16)}`)
|
||||
}
|
||||
|
||||
/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
|
||||
export interface SandboxInternals {
|
||||
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
|
||||
@@ -158,6 +140,13 @@ export interface SandboxInternals {
|
||||
/** The chain's verdict: which runner confines, and how completely it enforces. */
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }
|
||||
|
||||
/** One live session/workspace pair's private temp directory and capability. */
|
||||
interface AclTempCapability {
|
||||
dir: string
|
||||
writeSid: string
|
||||
grant: AclWriteGrant
|
||||
}
|
||||
|
||||
/**
|
||||
* The runner chain per platform — selection is BY PLATFORM first, probes
|
||||
* second: a platform's chain is probed in preference order only when it has
|
||||
@@ -189,13 +178,12 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> =
|
||||
bwrap: 'full',
|
||||
landlock: 'full',
|
||||
seatbelt: 'full',
|
||||
// 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists
|
||||
// close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are
|
||||
// absent from both — pinned by the runner's Public-probe and CIM-denial
|
||||
// regressions). FAT-class (non-ACL) targets are declared unsupported
|
||||
// (warn-only) in the backend README — outside the promise, not an
|
||||
// exception to it.
|
||||
'windows-acl': 'full',
|
||||
// WRITE_RESTRICTED needs Everyone in both restricting lists for process
|
||||
// initialization. An external object that grants Everyone write access
|
||||
// therefore remains writable, and NTFS hard links can alias a granted
|
||||
// workspace file to a path outside it. The backend enforces the remaining
|
||||
// ACL-addressable surface but must not advertise the absolute promise.
|
||||
'windows-acl': 'partial',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,8 +243,9 @@ const RUNNER_FAILURE_RULES = {
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
|
||||
* chain verdict and, on the windows-acl rung, the write grants
|
||||
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
|
||||
* and the revocable private-temp grant per session, the latter revoked on
|
||||
* provider dispose); the one-time probes spawn nothing else.
|
||||
* and the revocable private-temp grant per live session/workspace pair, the
|
||||
* latter revoked on provider dispose); the one-time probes spawn nothing
|
||||
* else.
|
||||
*/
|
||||
export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
@@ -278,12 +267,11 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* Server-lifetime write grants (windows-acl rung): the STANDING
|
||||
* workspace-root grant per workspace (its ACE is the cross-session reuse
|
||||
* cache and outlives the provider — never revoked) and the REVOCABLE
|
||||
* private-temp grant per session (revoked on provider dispose).
|
||||
* private-temp grant per live session/workspace pair (revoked on provider
|
||||
* dispose).
|
||||
*/
|
||||
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly tempGrants = new Map<string, AclWriteGrant>()
|
||||
/** Session id → the private temp directory this provider created (removed on dispose). */
|
||||
private readonly tempDirs = new Map<string, string>()
|
||||
private readonly tempCapabilities = new Map<string, AclTempCapability>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
@@ -357,20 +345,19 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
|
||||
/**
|
||||
* The windows-acl runner argv for one policy. With a calling session (the
|
||||
* policy's `sessionId`), the write grants are materialized once per server
|
||||
* lifetime — the standing workspace-root grant per workspace and the
|
||||
* revocable private-temp grant per session — and the runner receives
|
||||
* `--write-sid` (the workspace-derived identity; its presence marks the
|
||||
* seam-managed DACL contract) plus, under workspace-write, the session's
|
||||
* PRIVATE temp subdirectory (derived from session id + workspace) — it
|
||||
* grants nothing and revokes nothing. Agentless calls pass the ambient
|
||||
* temp root and no `--write-sid`: the runner self-manages its DACLs.
|
||||
* policy's `sessionId`) under workspace-write, the grants are materialized
|
||||
* once per provider lifetime — the standing workspace-root grant per
|
||||
* workspace and a revocable, RANDOM private-temp capability per live
|
||||
* session/workspace pair. The runner receives `--write-sid` plus
|
||||
* `--temp-write-sid` and grants nothing itself. Agentless workspace-write
|
||||
* calls pass the ambient temp ROOT and no SID flags: the runner creates and
|
||||
* removes a random private child directory for that one invocation.
|
||||
* @param policy - the resolved per-call policy.
|
||||
* @returns the runner invocation.
|
||||
*/
|
||||
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
|
||||
const sessionId = policy.sessionId
|
||||
if (sessionId === undefined) {
|
||||
if (sessionId === undefined || policy.mode === 'read-only') {
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
@@ -378,45 +365,33 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
'--mode', policy.mode,
|
||||
]
|
||||
}
|
||||
this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode)
|
||||
const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
// Workspace-write sessions confine their temp writes to the PRIVATE
|
||||
// per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only
|
||||
// runs pass the ambient temp root — the runner validates it exists
|
||||
// but grants nothing. The derived write SID is the per-workspace
|
||||
// identity; the flag's presence marks the seam-managed DACL contract.
|
||||
'--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(),
|
||||
'--temp', temp.dir,
|
||||
'--mode', policy.mode,
|
||||
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
|
||||
'--temp-write-sid', temp.writeSid,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the session's ACEs once per server lifetime: lazily at its
|
||||
* first confined execution, reused for every later call (the map hits are
|
||||
* the whole call). The write SID is the per-workspace identity derived
|
||||
* from the workspace. Workspace-write grants the workspace root STANDING
|
||||
* (the ACE outlives every session — the reuse cache) and the session's
|
||||
* private temp subdirectory REVOCABLY — the directory is derived from
|
||||
* session id + workspace, created here EXCLUSIVELY (a pre-existing entry
|
||||
* or a reparse point fails the first confined run loudly, so the grant
|
||||
* never lands on a foreign object); read-only materializes NOTHING — its
|
||||
* token alone restricts every write, and the standing grant from an
|
||||
* earlier workspace-write period is KEPT through a downgrade (never
|
||||
* revoked): the read-only restricted token carries no write SID (the
|
||||
* read-only list), so the ACE is inert there, while the map hit keeps the
|
||||
* re-upgrade free of re-propagation. Fail-closed: a half-materialized
|
||||
* temp grant is revoked before the error propagates.
|
||||
* Materialize one workspace-write policy's ACEs once per provider
|
||||
* lifetime. The workspace SID and standing root grant are shared by the
|
||||
* workspace. The temp directory is random and carries a distinct SID, so
|
||||
* another session on the same workspace cannot use the shared workspace
|
||||
* SID to enter it. A fresh provider always chooses a new path; crash
|
||||
* residue therefore cannot collide with or authorize a resumed session.
|
||||
* Fail-closed: a half-materialized temp grant is revoked and its directory
|
||||
* removed before the error propagates.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @param mode - the policy mode (grants exist only under workspace-write).
|
||||
* @returns the pair's private temp directory and write capability.
|
||||
*/
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void {
|
||||
if (mode === 'read-only') return
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
|
||||
assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
|
||||
const writeSid = workspaceWriteSid(workspaceRoot)
|
||||
const tempDir = sessionTempDir(sessionId, workspaceRoot)
|
||||
if (!this.workspaceGrants.has(workspaceRoot)) {
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
try {
|
||||
@@ -434,32 +409,37 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
}
|
||||
this.workspaceGrants.set(workspaceRoot, grant)
|
||||
}
|
||||
if (this.tempGrants.has(sessionId)) return
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
// The directory is removed again in the catch only when THIS confine
|
||||
// created it — a pre-existing entry (EEXIST) is a foreign object and is
|
||||
// never deleted.
|
||||
let created = false
|
||||
const key = JSON.stringify([String(sessionId), workspaceRoot])
|
||||
const existing = this.tempCapabilities.get(key)
|
||||
if (existing !== undefined) return existing
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
const tempSid = tempWriteSid(tempDir)
|
||||
let grant: AclWriteGrant | undefined
|
||||
try {
|
||||
// Exclusive creation (no `recursive`): a pre-existing entry OR a
|
||||
// reparse point both fail EEXIST — the grant never lands on a foreign
|
||||
// object.
|
||||
mkdirSync(tempDir)
|
||||
created = true
|
||||
grant = AclWriteGrant.create(tempSid)
|
||||
grant.add(tempDir)
|
||||
} catch (error) {
|
||||
if (created) rmSync(tempDir, { recursive: true, force: true })
|
||||
// Revoke whatever stands and free the SID — never leave a half-grant
|
||||
// behind a failed confine (the runner never runs).
|
||||
const cleanupFailures: unknown[] = []
|
||||
if (grant !== undefined) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
try {
|
||||
grant.dispose()
|
||||
this.removeTempDir(tempDir)
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.tempGrants.set(sessionId, grant)
|
||||
this.tempDirs.set(sessionId, tempDir)
|
||||
const capability = { dir: tempDir, writeSid: tempSid, grant }
|
||||
this.tempCapabilities.set(key, capability)
|
||||
return capability
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,36 +448,40 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* removed, and every SID allocation is freed; the standing workspace ACEs
|
||||
* stay (the reuse cache). Cleanup failures are reported, not thrown:
|
||||
* cordis teardown must not be aborted by grant cleanup. A crash skips all
|
||||
* of it — the next resume then fails loudly at the exclusive creation and
|
||||
* OS temp hygiene (or manual removal) recovers.
|
||||
* of it, but a new provider never reuses the residue's random path or SID;
|
||||
* OS temp hygiene (or manual removal) eventually reclaims it.
|
||||
*/
|
||||
private revokeAclGrants(): void {
|
||||
if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return
|
||||
if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
|
||||
const failures: unknown[] = []
|
||||
for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) {
|
||||
for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) })
|
||||
for (const dir of this.tempDirs.values()) {
|
||||
for (const { dir } of this.tempCapabilities.values()) {
|
||||
try {
|
||||
rmTempDir(dir)
|
||||
this.removeTempDir(dir)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.workspaceGrants.clear()
|
||||
this.tempGrants.clear()
|
||||
this.tempDirs.clear()
|
||||
this.tempCapabilities.clear()
|
||||
if (failures.length > 0) {
|
||||
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
|
||||
for (const error of failures) this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one provider-owned private temp directory (injectable for cleanup tests). */
|
||||
private removeTempDir(dir: string): void {
|
||||
const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) })
|
||||
remove(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which runner confines commands, once, for the provider's
|
||||
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
|
||||
@@ -529,8 +513,9 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' {
|
||||
// bwrap's mount profile and Seatbelt's deny-file-write* profile govern
|
||||
// every promised file effect by construction, so their passing probes
|
||||
// are always full enforcement; only the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial.
|
||||
// are always full enforcement; the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial, while windows-acl is always
|
||||
// partial for its documented Everyone and hard-link boundaries.
|
||||
switch (runner) {
|
||||
case 'bwrap': {
|
||||
const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs))
|
||||
@@ -547,7 +532,7 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
case 'windows-acl': {
|
||||
const probe = this.internals.probeWindowsAcl
|
||||
?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))
|
||||
return probe() ? 'full' : 'unusable'
|
||||
return probe() ? 'partial' : 'unusable'
|
||||
}
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
/**
|
||||
* windows-acl write grants: the SERVER-LIFETIME ACE materialization
|
||||
* (standing workspace grant per workspace, revocable private-temp grant per
|
||||
* session) plus the derived private-temp identity, through the REAL
|
||||
* LocalSandboxProvider.confine(). Win32 surface mocked at the package
|
||||
* boundary (the workspace-derived SID mocked to a constant); the real-FFI
|
||||
* grant behavior lives in sandbox-windows-acl's win32 tests.
|
||||
* windows-acl grant ownership through the real LocalSandboxProvider: one
|
||||
* standing capability per workspace plus one random, distinct, revocable
|
||||
* temp capability per live session/workspace pair. The Win32 grant surface
|
||||
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
||||
const mockState = vi.hoisted(() => ({
|
||||
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
|
||||
addFailure: undefined as Error | undefined,
|
||||
/** Restricts {@link addFailure} to this path (undefined = every add throws). */
|
||||
addFailurePath: undefined as string | undefined,
|
||||
/** Restrict an add failure to standing (workspace) or revocable (temp). */
|
||||
addFailureStanding: undefined as boolean | undefined,
|
||||
createTempFailure: undefined as Error | undefined,
|
||||
disposeFailure: undefined as Error | undefined,
|
||||
}))
|
||||
|
||||
@@ -35,24 +34,36 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
mockState.grants.push(this)
|
||||
}
|
||||
static create(writeSid: string): MockAclWriteGrant {
|
||||
if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure
|
||||
return new MockAclWriteGrant(writeSid)
|
||||
}
|
||||
add(path: string, standing = false): void {
|
||||
if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) {
|
||||
this.added.push({ path, standing })
|
||||
if (mockState.addFailure !== undefined
|
||||
&& (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) {
|
||||
throw mockState.addFailure
|
||||
}
|
||||
this.added.push({ path, standing })
|
||||
}
|
||||
dispose(): void {
|
||||
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' }
|
||||
return {
|
||||
AclWriteGrant: MockAclWriteGrant,
|
||||
assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => {
|
||||
const workspace = realpathSync.native(workspaceRoot)
|
||||
const temp = realpathSync.native(tempRoot)
|
||||
if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) {
|
||||
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
|
||||
}
|
||||
},
|
||||
workspaceWriteSid: () => 'S-1-4-42-42',
|
||||
tempWriteSid: (path: string) => `TEMP:${path}`,
|
||||
}
|
||||
})
|
||||
|
||||
/** The workspace-derived write SID the mock pins for every workspace. */
|
||||
const DERIVED_SID = 'S-1-4-42-42'
|
||||
const WORKSPACE_SID = 'S-1-4-42-42'
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
@@ -62,279 +73,232 @@ async function setup() {
|
||||
return { ctx, sandbox, fiber }
|
||||
}
|
||||
|
||||
/** A workspace root the policy carries. */
|
||||
function workspaceRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-'))
|
||||
}
|
||||
|
||||
function flag(argv: readonly string[], name: string): string | undefined {
|
||||
const index = argv.indexOf(name)
|
||||
return index < 0 ? undefined : argv[index + 1]
|
||||
}
|
||||
|
||||
describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.grants = []
|
||||
mockState.addFailure = undefined
|
||||
mockState.addFailurePath = undefined
|
||||
mockState.addFailureStanding = undefined
|
||||
mockState.createTempFailure = undefined
|
||||
mockState.disposeFailure = undefined
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
for (const grant of mockState.grants) {
|
||||
for (const added of grant.added) {
|
||||
if (!added.standing) rmSync(added.path, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => {
|
||||
it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-1'), ws)
|
||||
scratch.push(tempDir)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
|
||||
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
const tempDir = flag(confined.argv, '--temp')
|
||||
const tempSid = flag(confined.argv, '--temp-write-sid')
|
||||
expect(tempDir).toBeDefined()
|
||||
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
|
||||
expect(tempSid).toBe(`TEMP:${tempDir}`)
|
||||
expect(tempSid).not.toBe(WORKSPACE_SID)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--write-sid', WORKSPACE_SID,
|
||||
'--temp-write-sid', tempSid,
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked
|
||||
disposed: false,
|
||||
})
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true) // created exclusively
|
||||
expect(mockState.grants).toEqual([
|
||||
expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }),
|
||||
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
|
||||
])
|
||||
expect(existsSync(tempDir ?? '')).toBe(true)
|
||||
|
||||
// Reuse: the second confine is the map hits.
|
||||
sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
// dispose() runs on BOTH grants: the standing workspace ACE is left in
|
||||
// place (the mock marks it disposed only as instance teardown).
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true)
|
||||
expect(mockState.grants.every(grant => grant.disposed)).toBe(true)
|
||||
expect(existsSync(tempDir ?? '')).toBe(false)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => {
|
||||
it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const { sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-switch'), ws)
|
||||
scratch.push(tempDir)
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') }
|
||||
|
||||
// read-only first: nothing materialized, ambient temp.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toEqual([
|
||||
expect(sandbox.confine(['true'], readOnly).argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(tempDir)).toBe(false)
|
||||
|
||||
// Upgrade: first workspace-write materializes with the derived SID.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false })
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true)
|
||||
|
||||
// Reuse: map hits.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
|
||||
expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv)
|
||||
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => {
|
||||
it('a fresh provider gives a resumed session a new temp path and SID, so crash residue cannot collide', async () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const first = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
|
||||
const first = await setup()
|
||||
const firstConfined = first.sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
const firstTemp = flag(firstConfined.argv, '--temp') ?? ''
|
||||
|
||||
// Clean restart: dispose revokes the temp ACE and removes the private
|
||||
// temp directory, so the fresh provider's exclusive creation succeeds.
|
||||
await first.fiber.dispose()
|
||||
mockState.grants = []
|
||||
// The first provider remains live: model an unclean prior process whose
|
||||
// temp directory and ACE survived. A new provider must still proceed.
|
||||
const second = await setup()
|
||||
const secondConfined = second.sandbox.confine(['true'], policy)
|
||||
expect(secondConfined.argv).toEqual(firstConfined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }],
|
||||
})
|
||||
const secondTemp = flag(secondConfined.argv, '--temp') ?? ''
|
||||
expect(secondTemp).not.toBe(firstTemp)
|
||||
expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid'))
|
||||
expect(existsSync(firstTemp)).toBe(true)
|
||||
expect(existsSync(secondTemp)).toBe(true)
|
||||
|
||||
await second.fiber.dispose()
|
||||
await first.fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => {
|
||||
it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') }
|
||||
const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
|
||||
const { sandbox, fiber } = await setup()
|
||||
const wsA = workspaceRoot()
|
||||
const wsB = workspaceRoot()
|
||||
scratch.push(wsA, wsB)
|
||||
const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') })
|
||||
const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') })
|
||||
const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') })
|
||||
|
||||
sandbox.confine(['true'], parentPolicy)
|
||||
const parentTemp = sessionTempDir(SessionId('parent'), ws)
|
||||
scratch.push(parentTemp)
|
||||
sandbox.confine(['true'], childPolicy)
|
||||
const childTemp = sessionTempDir(SessionId('child'), ws)
|
||||
scratch.push(childTemp)
|
||||
expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp'))
|
||||
expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid'))
|
||||
expect(flag(moved.argv, '--temp')).not.toBe(flag(parent.argv, '--temp'))
|
||||
expect(mockState.grants).toHaveLength(5) // workspace A + two temps + workspace B + one temp
|
||||
|
||||
// Fresh temp identity, NOT the parent's (the workspace SID is shared by
|
||||
// derivation — the workspace is the same, so the standing grant is the
|
||||
// map hit and only the child's temp grant joins).
|
||||
expect(childTemp).not.toBe(parentTemp)
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] })
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => {
|
||||
it('workspace grant failure disposes its SID, aggregates cleanup failure, and never creates a temp directory', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it.
|
||||
const preexisting = sessionTempDir(SessionId('preexisting'), ws)
|
||||
mkdirSync(preexisting)
|
||||
scratch.push(preexisting)
|
||||
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
|
||||
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
|
||||
// The standing workspace grant is the intended end state and stays; the
|
||||
// failed temp grant self-disposes.
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked
|
||||
|
||||
// Reparse point: same EEXIST (exclusive mkdir never follows links).
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-'))
|
||||
scratch.push(target)
|
||||
const linkPath = sessionTempDir(SessionId('reparse'), ws)
|
||||
symlinkSync(target, linkPath)
|
||||
scratch.push(linkPath)
|
||||
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
|
||||
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
|
||||
// Same workspace as the preexisting case: the standing workspace grant
|
||||
// is the map hit (not recreated) — only the failed temp grant joins.
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]!.disposed).toBe(true)
|
||||
|
||||
// Temp-side cleanup failure: the standing workspace grant stays (map
|
||||
// hit), the exclusive mkdir fails, AND the temp grant's dispose also
|
||||
// fails — the temp cleanup AggregateError propagates.
|
||||
mockState.grants = []
|
||||
mockState.disposeFailure = new Error('temp cleanup exploded')
|
||||
const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws)
|
||||
mkdirSync(dupTemp)
|
||||
scratch.push(dupTemp)
|
||||
const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') }
|
||||
expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/)
|
||||
expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
|
||||
|
||||
// add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates.
|
||||
mockState.addFailure = new Error('grant exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
|
||||
mockState.addFailureStanding = true
|
||||
mockState.addFailure = new Error('workspace grant exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'),
|
||||
})).toThrow('workspace grant exploded')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
|
||||
// add() AND dispose() both throw: AggregateError.
|
||||
mockState.grants = []
|
||||
mockState.addFailure = new Error('grant exploded again')
|
||||
mockState.disposeFailure = new Error('cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError)
|
||||
mockState.disposeFailure = new Error('workspace cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'),
|
||||
})).toThrow(/workspace grant failed and its cleanup also failed/u)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => {
|
||||
it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => {
|
||||
const { sandbox } = await setup()
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'),
|
||||
})).toThrow(/temp root must be outside the workspace/u)
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') }
|
||||
|
||||
// The workspace grant succeeds; only the TEMP grant's add throws (the
|
||||
// path-targeted failure keeps the workspace branch intact).
|
||||
mockState.addFailurePath = tempDir
|
||||
mockState.createTempFailure = new Error('temp SID creation exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'),
|
||||
})).toThrow('temp SID creation exploded')
|
||||
expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed
|
||||
|
||||
mockState.createTempFailure = undefined
|
||||
mockState.addFailureStanding = false
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded')
|
||||
expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'),
|
||||
})).toThrow('temp add exploded')
|
||||
const failedTempGrant = mockState.grants.at(-1)
|
||||
expect(failedTempGrant?.disposed).toBe(true)
|
||||
expect(failedTempGrant?.added).toHaveLength(1)
|
||||
expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false)
|
||||
|
||||
mockState.addFailureStanding = false
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') }
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'),
|
||||
})).toThrow(/temp grant materialization failed and its cleanup also failed/u)
|
||||
delete sandbox.internals.rmTempDir
|
||||
|
||||
mockState.addFailureStanding = false
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
mockState.disposeFailure = new Error('temp cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'),
|
||||
})).toThrow(/temp grant materialization failed and its cleanup also failed/u)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => {
|
||||
it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' })
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', '/ws',
|
||||
@@ -350,55 +314,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
it('provider teardown reports grant and directory cleanup failures without aborting teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-dispose'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
const confined = sandbox.confine(['true'], {
|
||||
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'),
|
||||
})
|
||||
const tempDir = flag(confined.argv, '--temp') ?? ''
|
||||
mockState.disposeFailure = new Error('revoke exploded')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await fiber.dispose()
|
||||
// BOTH grants (standing workspace + revocable temp) fail their dispose.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') }
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await fiber.dispose()
|
||||
// Both grants dispose cleanly; only the directory removal fails.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 3 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' }))
|
||||
expect(existsSync(tempDir)).toBe(true) // injected removal failed; test cleanup reclaims it
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => {
|
||||
const base = sessionTempDir(SessionId('sess-a'), '/ws/a')
|
||||
expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/)
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base)
|
||||
expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace
|
||||
// The separator prevents id/workspace collisions from merging inputs.
|
||||
expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -381,7 +381,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
|
||||
@@ -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/sandbox/sandbox-windows-acl/README.md
|
||||
README.md: b13160f7490878143c719ca617936b74ffd298af
|
||||
README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44
|
||||
README.md: 280dc2b38844feff87eb792223b87ead251f3e16
|
||||
README.zh.md: 06121c3142bd788d0e1fe8cfa38fa8a668bb270a
|
||||
|
||||
@@ -2,52 +2,63 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
||||
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
||||
|
||||
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary).
|
||||
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE skip. Each live session/workspace pair instead receives a random temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Windows grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. These SIDs are the primary allowlists and grant nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement.
|
||||
|
||||
Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grant shape: read-only pairs with zero grants. workspace-write
|
||||
// REQUIRES the workspace's write SID — the per-workspace identity.
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
||||
// match the grant shape. workspace-write requires distinct workspace and
|
||||
// private-temp identities; pass tempDir: null to disable temp writes.
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceRoot],
|
||||
tempDir,
|
||||
writeSid: workspaceWriteSid(workspaceRoot),
|
||||
tempWriteSid: tempWriteSid(tempDir),
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
||||
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
```
|
||||
|
||||
A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction.
|
||||
A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction.
|
||||
|
||||
## The confinement runner
|
||||
|
||||
The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract:
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial.
|
||||
The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its self-managed temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial.
|
||||
|
||||
**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host).
|
||||
**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. A workspace equal to or containing that root is rejected before any grant because its inheritable workspace ACE would otherwise authorize every private child; the direct API likewise rejects overlap between any writable root and the actual private temp directory. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine.
|
||||
|
||||
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
||||
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection.
|
||||
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
- `workspace-write` (logon SID, Everyone, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries.
|
||||
- `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. Everyone's ambient rights remain the documented partial boundary. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
|
||||
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
|
||||
|
||||
The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle.
|
||||
The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir: null` to disable temp writes) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle.
|
||||
|
||||
## Header verification
|
||||
|
||||
@@ -61,17 +72,19 @@ The koffi struct definitions assert their sizes against the probe at module load
|
||||
|
||||
## Verified boundaries (inherent to restricted tokens, not this port)
|
||||
|
||||
- **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary.
|
||||
- **Hard links are file-object aliases, not path aliases.** An inheritable workspace ACE propagated onto an existing NTFS hard link changes the one underlying file security descriptor, so the same object is writable through an external alias. Rejecting every multiply-linked workspace file is not viable for ordinary pnpm installations, which use hard links into their content-addressable store; the native runner suite pins the gap and the provider's partial report names its consequence.
|
||||
- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement.
|
||||
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
|
||||
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls <dir> /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace.
|
||||
- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation.
|
||||
- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead.
|
||||
- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation.
|
||||
- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The actual temp directory must be disjoint from every writable root. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child, but reject a workspace equal to or containing that parent before any ACL mutation.
|
||||
- **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that two tokens sharing the same workspace SID cannot write one another's temp directories.
|
||||
- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection.
|
||||
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -80,12 +93,11 @@ None directly; the denial surface belongs to the tool layer.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
|
||||
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove.
|
||||
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual directory removal reclaims it.
|
||||
- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them.
|
||||
- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path.
|
||||
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
|
||||
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
|
||||
- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this.
|
||||
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). Private temp directories start empty, so their distinct grant is cheap. If a workspace is huge, the first confined write on this host is correspondingly slow.
|
||||
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
|
||||
- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated.
|
||||
- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage.
|
||||
- **PowerShell language mode differs by confined mode.** Under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Under the shipped `workspace-write` path, the private-temp capability lets that probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise; a direct `AclSandbox` configured with `tempDir: null` has no such guarantee and can fail the probe closed like read-only. This split is PowerShell startup behavior, not part of the ACL write boundary. The `pwsh` tool description teaches the shipped modes to the model; `danger-full-access` calls run unconfined at FullLanguage.
|
||||
|
||||
@@ -2,32 +2,43 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。
|
||||
面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。
|
||||
|
||||
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。
|
||||
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。
|
||||
|
||||
直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
|
||||
|
||||
## 用法
|
||||
|
||||
```ts
|
||||
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grant shape: read-only pairs with zero grants. workspace-write
|
||||
// REQUIRES the workspace's write SID — the per-workspace identity.
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
||||
// match the grant shape. workspace-write requires distinct workspace and
|
||||
// private-temp identities; pass tempDir: null to disable temp writes.
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceRoot],
|
||||
tempDir,
|
||||
writeSid: workspaceWriteSid(workspaceRoot),
|
||||
tempWriteSid: tempWriteSid(tempDir),
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
||||
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
```
|
||||
|
||||
直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。
|
||||
直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。
|
||||
|
||||
<a id="the-confinement-runner"></a>
|
||||
|
||||
@@ -36,20 +47,20 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work
|
||||
面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约:
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…> --temp-write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
|
||||
runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
|
||||
|
||||
**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。
|
||||
**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。工作区若等于或包含该根目录,会在任何授权前被拒绝,因为否则其可继承的工作区 ACE 会向每个私有子目录授权;直接 API 同样拒绝任何可写根目录与实际私有临时目录重叠。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。
|
||||
|
||||
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
|
||||
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
- `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID):工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
|
||||
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
|
||||
|
||||
`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。
|
||||
`AclSandbox` 类(显式私有 `tempDir` + `tempWriteSid`,或用 `tempDir: null` 禁用临时写入)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。
|
||||
|
||||
## 头部验证
|
||||
|
||||
@@ -63,17 +74,19 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
|
||||
## 已验证边界(受限令牌固有,非本移植引入)
|
||||
|
||||
- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。
|
||||
- **硬链接是文件对象别名,而非路径别名。** 传播到已有 NTFS 硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入。拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接指向其内容寻址存储;原生 runner 套件钉住该缺口,提供方的部分强制执行报告则点明其后果。
|
||||
- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。
|
||||
- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。
|
||||
- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
|
||||
- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。
|
||||
- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`<temp>\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。
|
||||
- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。
|
||||
- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。实际临时目录不得与任何可写根目录重叠。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录并自行创建随机子目录,但如果工作区等于或包含该父根目录,就会在任何 ACL 改动前拒绝调用。
|
||||
- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID,而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。
|
||||
- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
|
||||
间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -82,12 +95,11 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。
|
||||
- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。
|
||||
- **清理按设计尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。
|
||||
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。
|
||||
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。
|
||||
- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
||||
- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。私有临时目录创建时为空,因此其独立授权开销很小。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
||||
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
||||
- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。
|
||||
- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。
|
||||
- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。交付的 `workspace-write` 路径拥有私有临时目录能力,可使该探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage;直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证,探针可能像 read-only 一样失败并按 fail-closed 处理。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。`pwsh` 工具描述向模型传授这些交付模式;`danger-full-access` 调用不受限地在 FullLanguage 下运行。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
|
||||
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam",
|
||||
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ACL editing helpers: grant/revoke the orphan write SID on a directory via
|
||||
* ACL editing helpers: grant/revoke a capability SID on a directory via
|
||||
* SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
|
||||
* the failure handling the POC lacks). Every API call is checked and every
|
||||
* failure is reported with the API name, the exact Win32 code, the formatted
|
||||
@@ -39,7 +39,7 @@ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions
|
||||
entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation
|
||||
entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm
|
||||
entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType
|
||||
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID
|
||||
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the capability SID
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -181,16 +181,16 @@ function mergeAndApply(
|
||||
/**
|
||||
* True when the explicit DACL already carries the EXACT write grant this
|
||||
* module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the
|
||||
* orphan SID). Every field is read through koffi.decode at pointer offsets —
|
||||
* capability SID). Every field is read through koffi.decode at pointer offsets —
|
||||
* no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the
|
||||
* ACE after the 4-byte mask — there is no pointer to read; reading one
|
||||
* yields garbage addresses and crashed EqualSid, verified by gdb), so it is
|
||||
* compared field-by-field against the orphan SID through bounded offset
|
||||
* compared field-by-field against the capability SID through bounded offset
|
||||
* reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
|
||||
* so the caller falls back to the merge-apply path, which owns the robust
|
||||
* failure handling.
|
||||
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
|
||||
* @param sidPtr - the orphan write SID to match.
|
||||
* @param sidPtr - the capability SID to match.
|
||||
* @returns whether the exact grant ACE is already present.
|
||||
*/
|
||||
function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
|
||||
@@ -213,7 +213,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
|
||||
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID
|
||||
* on `path`, inheriting to subcontainers and objects. Idempotent: when the
|
||||
* directory's current explicit DACL already carries the exact ACE (the
|
||||
* per-session grant surviving from a previous server lifetime), the
|
||||
@@ -226,7 +226,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
|
||||
* precondition as the POC.
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory whose DACL gains the grant (the workspace or temp root).
|
||||
* @param sidPtr - the orphan write SID the ACE names.
|
||||
* @param sidPtr - the capability SID the ACE names.
|
||||
*/
|
||||
export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void {
|
||||
withPathLock(api, path, () => {
|
||||
@@ -244,15 +244,15 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr):
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
|
||||
* Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS
|
||||
* merge — other entries are preserved). Returns whether an ACE removal was
|
||||
* attempted (false when the directory carries no DACL at all).
|
||||
*
|
||||
* Runs under the per-path lock (the whole get-merge-set sequence); the
|
||||
* descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory whose DACL loses the orphan-SID ACEs.
|
||||
* @param sidPtr - the orphan write SID whose ACEs are removed.
|
||||
* @param path - the directory whose DACL loses the capability-SID ACEs.
|
||||
* @param sidPtr - the capability SID whose ACEs are removed.
|
||||
* @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
|
||||
*/
|
||||
export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Server-side per-session write grant: the ACE materialization half of the
|
||||
* sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE
|
||||
* {@link AclWriteGrant} per session for the server process's lifetime —
|
||||
* created lazily at the session's first confined execution, reused (never
|
||||
* re-applied) for every later call, revoked on provider dispose. The durable
|
||||
* half (the session's SID and paths surviving a restart) lives in the
|
||||
* session log, owned by the seam; this module owns only the native half: the
|
||||
* parsed SID pointer and the standing ACEs.
|
||||
* Server-side write-grant materialization. The sandbox seam holds one
|
||||
* standing workspace grant per workspace and one revocable temp grant per
|
||||
* live session/workspace pair. Workspace identities survive by deterministic
|
||||
* derivation and their standing ACE; temp identities derive from random
|
||||
* private paths and are deliberately new after a restart.
|
||||
*
|
||||
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
||||
* instance (revoking every path granted so far); `dispose` revokes every
|
||||
@@ -19,7 +16,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '.
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
|
||||
/**
|
||||
* One write SID's server-lifetime grant materialization: the parsed SID
|
||||
* One write SID's provider-lifetime grant materialization: the parsed SID
|
||||
* pointer plus every directory whose DACL currently carries its ACE.
|
||||
* Workspace paths are added STANDING (their ACEs are the cross-session reuse
|
||||
* cache and outlive the grant — dispose() skips revoking them, or the next
|
||||
@@ -45,7 +42,7 @@ export class AclWriteGrant {
|
||||
/**
|
||||
* Parse the SID string and open the binding table (lazily, once per
|
||||
* server). Fail-closed: any failure throws — nothing is granted yet.
|
||||
* @param writeSid - the orphan write SID string (`S-1-4-x-y`).
|
||||
* @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string.
|
||||
* @param api - optional already-resolved bindings (tests).
|
||||
* @returns the ready grant (no ACEs yet).
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Windows ACL write-restriction sandbox backend for the DeepSeek Harness
|
||||
* sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/
|
||||
* windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED
|
||||
* token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only
|
||||
* this sandbox adds to the target directories' DACLs — the intersection
|
||||
* check then allows writes exactly where that SID has a Write ACE, and
|
||||
* nowhere else the write SID is concerned (the token's write check ALSO
|
||||
* token whose restricting SIDs include distinct workspace and temp write
|
||||
* SIDs that this sandbox adds to their owning directories' DACLs — the
|
||||
* intersection check then allows writes exactly where either capability has
|
||||
* a Write ACE, and nowhere else those SIDs are concerned (the check ALSO
|
||||
* inherits the ambient write ACEs of the other restricting SIDs — the
|
||||
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
|
||||
* and LOCAL are absent from both lists — see the seam's dual-list contract
|
||||
@@ -15,7 +15,9 @@
|
||||
* path, so the workspace-root ACE materializes once per workspace per
|
||||
* machine and every later provision hits the exact-ACE skip — the
|
||||
* grant-reuse story the per-session random SID paid a full tree propagation
|
||||
* per session for. Unlike the POC, every API failure throws with the API
|
||||
* per session for. Each private temp directory instead receives its own SID,
|
||||
* so sibling sessions sharing a workspace cannot enter one another's temp
|
||||
* trees. Unlike the POC, every API failure throws with the API
|
||||
* name and exact Win32 code; a child is NEVER spawned unrestricted.
|
||||
*
|
||||
* Known boundaries (inherent to restricted tokens, not this port):
|
||||
@@ -24,15 +26,14 @@
|
||||
* - console isolation is unavailable — children share the host console
|
||||
* (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with
|
||||
* STATUS_DLL_INIT_FAILED under the restriction);
|
||||
* - the temp directory and every writable directory must be owned by the
|
||||
* - the private temp directory and every writable directory must be owned by the
|
||||
* caller (owner-implicit WRITE_DAC);
|
||||
* - grants are standing ACE mutations on real directories. WORKSPACE grants
|
||||
* are deliberately never revoked — the ACE is the cross-session reuse
|
||||
* cache (revoking would force the next session to re-propagate the whole
|
||||
* tree). TEMP grants are revocable: dispose() removes them so a standing
|
||||
* inheritable ACE never outlives its session's temp directory (an
|
||||
* inheritable ACE on the ambient temp root would otherwise widen the
|
||||
* SID's write reach to every future temp file). With `manageDacls: false`
|
||||
* inheritable ACE never outlives its session's temp directory. The
|
||||
* ambient temp root is never granted implicitly. With `manageDacls: false`
|
||||
* the CALLER owns the DACLs (the sandbox seam's grant reuse):
|
||||
* init()/dispose() skip grant/revoke entirely and the caller must not
|
||||
* revoke under live children.
|
||||
@@ -44,26 +45,27 @@ import { resolve } from 'node:path'
|
||||
|
||||
import { grantWrite, revokeWrite } from './acl.ts'
|
||||
import { Win32Error } from './errors.ts'
|
||||
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
|
||||
import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { assertPrivateTempDisjoint } from './path-boundary.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
export { quoteArg } from './spawn.ts'
|
||||
export { AclWriteGrant } from './grant.ts'
|
||||
export { workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
|
||||
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { Win32Error } from './errors.ts'
|
||||
|
||||
/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */
|
||||
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
|
||||
export interface AclSandboxOptions {
|
||||
/** Directories the confined child may write into (must exist and be caller-owned). */
|
||||
writableDirs: readonly string[]
|
||||
/**
|
||||
* Temp directory to also grant; defaults to GetTempPathW() at init time.
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero grant on
|
||||
* the filesystem; the NUL device stays ambient-writable via Everyone — see
|
||||
* README).
|
||||
* Existing private temp directory to grant. Workspace-write callers must
|
||||
* pass it explicitly or pass null to disable temp writes; the ambient temp
|
||||
* root is never an implicit grant. Read-only accepts only null/undefined.
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
@@ -74,6 +76,13 @@ export interface AclSandboxOptions {
|
||||
* outlives every instance and later provisions hit the exact-ACE skip.
|
||||
*/
|
||||
writeSid?: string
|
||||
/**
|
||||
* The private temp directory's write SID. Required whenever
|
||||
* workspace-write grants a temp directory, absent otherwise. It must be
|
||||
* distinct from {@link writeSid}, so sibling sessions sharing a workspace
|
||||
* cannot use the standing workspace capability in one another's temp tree.
|
||||
*/
|
||||
tempWriteSid?: string
|
||||
/**
|
||||
* The file-effect mode this instance confines under — selects the
|
||||
* restricted token's restricting-SID list (I for read-only, J for
|
||||
@@ -85,7 +94,7 @@ export interface AclSandboxOptions {
|
||||
/**
|
||||
* Whether this instance owns its DACL grants (default true). False means
|
||||
* the CALLER has already materialized the ACEs (the sandbox seam's
|
||||
* per-session grant reuse): init()/dispose() skip grant/revoke entirely —
|
||||
* workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely —
|
||||
* the caller holds the grants for its own lifetime and revokes them.
|
||||
*/
|
||||
manageDacls?: boolean
|
||||
@@ -123,6 +132,22 @@ export interface AclSandboxChild {
|
||||
wait(): Promise<AclSandboxChildResult>
|
||||
}
|
||||
|
||||
/** Free one optional SID while retaining a failure for best-effort sibling cleanup. */
|
||||
function freeSidBestEffort(
|
||||
api: Win32Bindings,
|
||||
sidPtr: NativePtr | undefined,
|
||||
label: string,
|
||||
failures: unknown[],
|
||||
): void {
|
||||
if (sidPtr === undefined) return
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One write-restricted sandbox instance: token + write-SID grants + spawn.
|
||||
* `init()` is fail-closed — any Win32 failure revokes the revocable (temp)
|
||||
@@ -135,8 +160,10 @@ export interface AclSandboxChild {
|
||||
export class AclSandbox {
|
||||
/** Absolute writable directories (constructor-validated). */
|
||||
readonly writableDirs: string[]
|
||||
/** The write SID string whose ACEs form the write allowlist (workspace-write only). */
|
||||
/** The workspace SID string whose ACEs form the workspace allowlist. */
|
||||
readonly writeSid: string | undefined
|
||||
/** The private temp directory's write SID (workspace-write with temp only). */
|
||||
readonly tempWriteSid: string | undefined
|
||||
/** The file-effect mode — the restricted token's restricting-SID list selection. */
|
||||
readonly mode: 'read-only' | 'workspace-write'
|
||||
private readonly tempDirOption: string | null | undefined
|
||||
@@ -145,9 +172,10 @@ export class AclSandbox {
|
||||
private api: Win32Bindings | undefined
|
||||
private token: NativePtr | undefined
|
||||
private writeSidPtr: NativePtr | undefined
|
||||
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */
|
||||
private tempWriteSidPtr: NativePtr | undefined
|
||||
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */
|
||||
private sidAllocations: NativePtr[] = []
|
||||
private grantedPaths: string[] = []
|
||||
private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = []
|
||||
|
||||
constructor(options: AclSandboxOptions) {
|
||||
this.mode = options.mode
|
||||
@@ -161,9 +189,28 @@ export class AclSandbox {
|
||||
})
|
||||
this.tempDirOption = options.tempDir
|
||||
this.writeSid = options.writeSid
|
||||
this.tempWriteSid = options.tempWriteSid
|
||||
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
|
||||
}
|
||||
if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) {
|
||||
throw new Error('AclSandbox read-only does not accept a temp directory')
|
||||
}
|
||||
if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) {
|
||||
throw new Error('AclSandbox read-only does not accept write SIDs')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) {
|
||||
throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()')
|
||||
}
|
||||
if (this.tempDirOption === null && this.tempWriteSid !== undefined) {
|
||||
throw new Error('AclSandbox temp write SID requires a temp directory')
|
||||
}
|
||||
if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) {
|
||||
throw new Error('AclSandbox workspace and temp write SIDs must be distinct')
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved temp directory (available after init; null when temp grants are disabled). */
|
||||
@@ -171,56 +218,56 @@ export class AclSandbox {
|
||||
return this.tempDirResolved
|
||||
}
|
||||
|
||||
/** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */
|
||||
/** Create the restricted token and apply the capability-SID grants. Idempotent-unsafe: once per instance. */
|
||||
async init(): Promise<void> {
|
||||
if (this.api !== undefined) throw new Error('AclSandbox is already initialized')
|
||||
const api = await win32()
|
||||
|
||||
const currentToken = openCurrentProcessToken(api)
|
||||
let currentTokenOpen = true
|
||||
let restrictedToken: NativePtr | undefined
|
||||
try {
|
||||
// Read-only runs carry no write SID (its restricting list has no
|
||||
// orphan): nothing to parse, nothing to grant.
|
||||
let writeSidPtr: NativePtr | undefined
|
||||
if (this.writeSid !== undefined) {
|
||||
const parseSid = (sid: string): NativePtr => {
|
||||
const sidSlot = allocPtrSlot()
|
||||
if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) {
|
||||
throwLastError(api, 'ConvertStringSidToSidW', this.writeSid)
|
||||
if (api.convertStringSidToSidW(sid, sidSlot) === 0) {
|
||||
throwLastError(api, 'ConvertStringSidToSidW', sid)
|
||||
}
|
||||
const parsedSid = decodePtr(sidSlot)
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid)
|
||||
this.writeSidPtr = parsedSid
|
||||
writeSidPtr = parsedSid
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid)
|
||||
return parsedSid
|
||||
}
|
||||
this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid)
|
||||
this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid)
|
||||
|
||||
const tempDir = this.tempDirOption === null
|
||||
? null
|
||||
: this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api)
|
||||
const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption
|
||||
/* v8 ignore next -- constructor validation requires workspace-write to supply
|
||||
an explicit temp directory or null; the other branches normalize to null. */
|
||||
if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved')
|
||||
if (tempDir !== null) {
|
||||
if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) {
|
||||
throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`)
|
||||
}
|
||||
this.tempDirResolved = tempDir
|
||||
assertPrivateTempDisjoint(this.writableDirs, tempDir)
|
||||
}
|
||||
this.tempDirResolved = tempDir
|
||||
|
||||
// manageDacls: false — the caller (the sandbox seam's grant) already
|
||||
// materialized the ACEs; this instance must neither add nor remove any.
|
||||
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
|
||||
// per-workspace reuse cache — dispose() never revokes them, or the next
|
||||
// provision would re-propagate the whole tree) and the temp ACE is
|
||||
// REVOCABLE (dispose() removes it — an inheritable ACE on the ambient
|
||||
// temp root must not outlive the instance, or it would widen the SID's
|
||||
// write reach to every future temp file).
|
||||
// REVOCABLE (dispose() removes it before the private directory is
|
||||
// deleted; the ambient temp root is never granted).
|
||||
if (this.manageDacls) {
|
||||
if (writeSidPtr !== undefined) {
|
||||
if (this.writeSidPtr !== undefined) {
|
||||
for (const path of this.writableDirs) {
|
||||
grantWrite(api, path, writeSidPtr)
|
||||
grantWrite(api, path, this.writeSidPtr)
|
||||
}
|
||||
if (tempDir !== null) {
|
||||
if (tempDir !== null && this.tempWriteSidPtr !== undefined) {
|
||||
// Record BEFORE granting: grantWrite can throw after a successful
|
||||
// apply (a LocalFree failure), and the fail-closed catch must still
|
||||
// revoke that path (revoking an ungranted path is a no-op merge).
|
||||
this.grantedPaths.push(tempDir)
|
||||
grantWrite(api, tempDir, writeSidPtr)
|
||||
this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr })
|
||||
grantWrite(api, tempDir, this.tempWriteSidPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,58 +275,63 @@ export class AclSandbox {
|
||||
this.sidAllocations.push(logonSid)
|
||||
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
|
||||
this.sidAllocations.push(worldSid)
|
||||
const restricted = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSidPtr,
|
||||
const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined)
|
||||
restrictedToken = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSids,
|
||||
{ world: worldSid },
|
||||
this.mode,
|
||||
)
|
||||
this.token = restrictedToken
|
||||
// The restricted token's default DACL still names only the user's
|
||||
// ambient SIDs — none of the restricting SIDs. Every NEW object the
|
||||
// confined process creates (anonymous stdio pipes, sync objects) takes
|
||||
// its DACL from that default, so the write pass-2 check would deny
|
||||
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
|
||||
// piped-stdio grandchild spawn. Merge a full-access ACE for a
|
||||
// restricting SID (the write SID under workspace-write, Everyone under
|
||||
// read-only): new-object creation stays gated by the parent object's
|
||||
// DACL, while the new object's own DACL passes pass-2.
|
||||
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
|
||||
this.token = restricted
|
||||
// restricting SID (the PRIVATE temp SID when present, otherwise the
|
||||
// workspace SID, or Everyone under read-only): new-object creation
|
||||
// stays gated by the parent object's DACL, while the new object's own
|
||||
// DACL passes pass-2. Choosing the temp SID prevents default-DACL
|
||||
// objects in one session's temp tree from acquiring the shared
|
||||
// workspace capability.
|
||||
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid)
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
currentTokenOpen = false
|
||||
this.api = api
|
||||
} catch (error) {
|
||||
// Best-effort close on the failure path (last error already captured in `error`).
|
||||
api.closeHandle(currentToken)
|
||||
// FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted
|
||||
// token handle and the parsed write SID — this.api stays undefined, so dispose()
|
||||
// early-returns and cannot clean them up. Close the token and free the write SID
|
||||
// here (the hardening-followup rework already does both).
|
||||
// Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID
|
||||
// allocations a failed init left behind. Standing workspace ACEs are NOT
|
||||
// Fail-closed cleanup: never leave a revocable (temp) grant or SID
|
||||
// allocation behind a failed init. Standing workspace ACEs are NOT
|
||||
// revoked — they are the intended end state (the reuse cache), not an
|
||||
// error artifact.
|
||||
const cleanupFailures: unknown[] = []
|
||||
const writeSidPtr = this.writeSidPtr
|
||||
if (writeSidPtr !== undefined) {
|
||||
for (const path of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, path, writeSidPtr)
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
if (currentTokenOpen && api.closeHandle(currentToken) === 0) {
|
||||
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure'))
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) {
|
||||
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure'))
|
||||
}
|
||||
for (const grant of this.grantedPaths) {
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
|
||||
revokeWrite(api, grant.path, grant.sidPtr)
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
freeSidBestEffort(api, sidPtr, label, cleanupFailures)
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures)
|
||||
}
|
||||
this.token = undefined
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.tempDirResolved = undefined
|
||||
this.grantedPaths = []
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupFailures],
|
||||
`AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`,
|
||||
`AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -345,23 +397,17 @@ export class AclSandbox {
|
||||
const api = this.api
|
||||
if (api === undefined) return
|
||||
const failures: unknown[] = []
|
||||
const writeSidPtr = this.writeSidPtr
|
||||
if (writeSidPtr !== undefined) {
|
||||
if (this.manageDacls) {
|
||||
for (const path of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, path, writeSidPtr)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (this.manageDacls) {
|
||||
for (const grant of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, grant.path, grant.sidPtr)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const freed = api.localFree(writeSidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
freeSidBestEffort(api, sidPtr, label, failures)
|
||||
}
|
||||
const token = this.token
|
||||
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
|
||||
@@ -374,16 +420,12 @@ export class AclSandbox {
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
freeSidBestEffort(api, sidPtr, 'init SID allocation', failures)
|
||||
}
|
||||
this.api = undefined
|
||||
this.token = undefined
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.grantedPaths = []
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`)
|
||||
|
||||
40
packages/sandbox/sandbox-windows-acl/src/path-boundary.ts
Normal file
40
packages/sandbox/sandbox-windows-acl/src/path-boundary.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Canonical directory-boundary checks for the Windows ACL workspace and
|
||||
* private-temp capabilities.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/path-boundary
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
|
||||
/** Whether `root` is the same canonical directory as `candidate` or contains it. */
|
||||
function containsDirectory(root: string, candidate: string): boolean {
|
||||
const relation = relative(realpathSync.native(root), realpathSync.native(candidate))
|
||||
return relation === '' || (!isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${sep}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a temp parent that is inside the workspace: every child created
|
||||
* below it would inherit the standing workspace capability.
|
||||
* @param workspaceRoot - the canonical workspace root that receives the standing ACE.
|
||||
* @param tempRoot - the existing parent beneath which a private temp child would be created.
|
||||
*/
|
||||
export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void {
|
||||
if (containsDirectory(workspaceRoot, tempRoot)) {
|
||||
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject overlap between an actual private temp directory and any writable
|
||||
* directory: either inheritance direction would merge the two capabilities.
|
||||
* @param writableDirs - directories carrying the standing workspace capability.
|
||||
* @param tempDir - the existing directory carrying the revocable temp capability.
|
||||
*/
|
||||
export function assertPrivateTempDisjoint(writableDirs: readonly string[], tempDir: string): void {
|
||||
for (const writableDir of writableDirs) {
|
||||
if (containsDirectory(writableDir, tempDir) || containsDirectory(tempDir, writableDir)) {
|
||||
throw new Error(`AclSandbox private temp directory must be disjoint from writable directories: writable=${writableDir}; temp=${tempDir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,33 +10,29 @@
|
||||
* keep the same contract):
|
||||
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
|
||||
* '--mode', <read-only|workspace-write>,
|
||||
* ['--write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
* ['--write-sid', <S-1-4-…>,
|
||||
* '--temp-write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
*
|
||||
* Modes:
|
||||
* - workspace-write: the workspace and temp directories carry the orphan-SID
|
||||
* Write grant; every other write is denied by the token intersection.
|
||||
* - read-only: STRICT zero grants — no directory is writable, not even the
|
||||
* NUL device (`> $null` fails with access denied); the restricting list
|
||||
* carries no orphan SID, so a standing grant ACE from an earlier
|
||||
* - workspace-write: the workspace and temp directories carry distinct
|
||||
* capability-SID Write grants; other ACL-addressable writes are denied
|
||||
* except for the documented Everyone and hard-link boundaries.
|
||||
* - read-only: no capability-SID grants; the restricting list carries no
|
||||
* capability SID, so a standing grant ACE from an earlier
|
||||
* workspace-write period stays inert. BOTH modes drop Authenticated Users
|
||||
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
|
||||
* Public tree writes are denied); the two lists share the keep-alive group
|
||||
* (logon SID, EVERYONE) and differ only by the orphan.
|
||||
* (logon SID, EVERYONE) and differ only by the capabilities.
|
||||
*
|
||||
* `--write-sid`: the seam's grant contract — the CALLER has already
|
||||
* materialized the write-SID ACEs (the seam's workspace + private-temp
|
||||
* grants, server lifetime) and owns their revocation, so the runner neither
|
||||
* grants nor revokes (manageDacls: false). The carried SID is the
|
||||
* per-workspace identity ({@link workspaceWriteSid}) — the seam derives it
|
||||
* from the policy root; the flag's PRESENCE is the seam-managed marker (its
|
||||
* value must equal the workspace-derived SID). Absent `--write-sid`
|
||||
* (standalone/test use) the runner self-manages grants per invocation with
|
||||
* the same workspace-derived SID (its workspace ACEs are standing — the
|
||||
* reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in
|
||||
* workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN
|
||||
* environment (SetEnvironmentVariableW) to the `--temp` directory — a
|
||||
* PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs
|
||||
* /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment
|
||||
* `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
|
||||
* CALLER has already materialized distinct workspace and private-temp ACEs
|
||||
* and owns their revocation, so the runner neither grants nor revokes
|
||||
* (`manageDacls: false`). Both values are checked against their owning paths.
|
||||
* Without the pair (standalone/agentless use), workspace-write treats
|
||||
* `--temp` as a ROOT, creates a random private child directory, derives its
|
||||
* own temp SID, and removes that directory after the child exits. In both
|
||||
* flows the runner rewrites TMP/TEMP in its OWN environment to the private
|
||||
* directory before spawning; the child inherits that block (`lpEnvironment`
|
||||
* NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in
|
||||
* CreateProcessAsUserW, verified empirically). Read-only leaves the ambient
|
||||
* temp entries untouched (writes there are denied anyway).
|
||||
@@ -48,11 +44,12 @@
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/runner
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { win32 } from './ffi.ts'
|
||||
import { AclSandbox } from './index.ts'
|
||||
import { workspaceWriteSid } from './workspace-sid.ts'
|
||||
import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts'
|
||||
import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
|
||||
const RUNNER_SIGNATURE = 'windows-acl-run'
|
||||
const RUNNER_FAILURE_EXIT = 127
|
||||
@@ -70,6 +67,7 @@ interface ParsedArgs {
|
||||
temp: string
|
||||
mode: 'read-only' | 'workspace-write'
|
||||
writeSid: string | undefined
|
||||
tempWriteSid: string | undefined
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
@@ -79,6 +77,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
let temp: string | undefined
|
||||
let mode: string | undefined
|
||||
let writeSid: string | undefined
|
||||
let parsedTempWriteSid: string | undefined
|
||||
let index = 0
|
||||
for (; index < raw.length; index++) {
|
||||
const token = raw[index]
|
||||
@@ -94,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
case '--temp': temp = value; break
|
||||
case '--mode': mode = value; break
|
||||
case '--write-sid': writeSid = value; break
|
||||
case '--temp-write-sid': parsedTempWriteSid = value; break
|
||||
default: fail(`unknown argument: ${token}`)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
const argv = raw.slice(index)
|
||||
const command = argv[0]
|
||||
if (command === undefined) fail('missing command after --')
|
||||
return { workspace, temp, mode, writeSid, command, args: argv.slice(1) }
|
||||
return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
|
||||
}
|
||||
|
||||
function requireDirectory(label: string, path: string): void {
|
||||
@@ -119,6 +119,17 @@ async function main(): Promise<number> {
|
||||
requireDirectory('--workspace', parsed.workspace)
|
||||
requireDirectory('--temp', parsed.temp)
|
||||
|
||||
const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
|
||||
if (parsed.mode === 'read-only' && seamManaged) {
|
||||
fail('read-only does not accept --write-sid or --temp-write-sid')
|
||||
}
|
||||
if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
|
||||
fail('workspace-write requires --write-sid and --temp-write-sid together')
|
||||
}
|
||||
if (parsed.mode === 'workspace-write') {
|
||||
assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp)
|
||||
}
|
||||
|
||||
const api = await win32()
|
||||
// Ignore this process's own CTRL+C: the confined child (same console) keeps
|
||||
// handling its own; the runner must survive to revoke grants and mirror the
|
||||
@@ -127,36 +138,46 @@ async function main(): Promise<number> {
|
||||
fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
|
||||
// The write SID is the per-workspace identity in BOTH flows; the flag's
|
||||
// presence (seam-derived, or the self-managed derivation) selects who
|
||||
// owns the DACLs below.
|
||||
const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
|
||||
tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null,
|
||||
mode: parsed.mode,
|
||||
...writeSid === undefined ? {} : { writeSid },
|
||||
// With --write-sid the seam owns the DACLs (workspace + private-temp
|
||||
// grants): this invocation must neither add nor revoke ACEs.
|
||||
manageDacls: parsed.writeSid === undefined,
|
||||
})
|
||||
await sandbox.init()
|
||||
|
||||
// The seam's per-session temp contract: under --write-sid, workspace-write
|
||||
// children see the PRIVATE per-session temp subdirectory through TMP/TEMP
|
||||
// (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment
|
||||
// (SetEnvironmentVariableW) and the child inherits the block; self-managed
|
||||
// and read-only runs keep the ambient entries.
|
||||
if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) {
|
||||
if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) {
|
||||
fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) {
|
||||
fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
}
|
||||
|
||||
let ownedTempDir: string | undefined
|
||||
let sandbox: AclSandbox | undefined
|
||||
let initialized = false
|
||||
try {
|
||||
let privateTempDir: string | null = null
|
||||
let writeSid: string | undefined
|
||||
let privateTempSid: string | undefined
|
||||
if (parsed.mode === 'workspace-write') {
|
||||
writeSid = workspaceWriteSid(parsed.workspace)
|
||||
if (seamManaged) {
|
||||
if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace')
|
||||
privateTempDir = parsed.temp
|
||||
privateTempSid = tempWriteSid(privateTempDir)
|
||||
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
|
||||
} else {
|
||||
ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-'))
|
||||
privateTempDir = ownedTempDir
|
||||
privateTempSid = tempWriteSid(privateTempDir)
|
||||
}
|
||||
}
|
||||
sandbox = new AclSandbox({
|
||||
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
|
||||
tempDir: privateTempDir,
|
||||
mode: parsed.mode,
|
||||
...writeSid === undefined ? {} : { writeSid },
|
||||
...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid },
|
||||
manageDacls: !seamManaged,
|
||||
})
|
||||
await sandbox.init()
|
||||
initialized = true
|
||||
|
||||
if (privateTempDir !== null) {
|
||||
if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) {
|
||||
fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) {
|
||||
fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
}
|
||||
|
||||
const child = sandbox.spawn({
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
@@ -166,10 +187,19 @@ async function main(): Promise<number> {
|
||||
return result.exitCode
|
||||
} finally {
|
||||
// Cleanup failures must not mask the child's exit code: report and keep going.
|
||||
try {
|
||||
sandbox.dispose()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
if (initialized) {
|
||||
try {
|
||||
sandbox?.dispose()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
}
|
||||
if (ownedTempDir !== undefined) {
|
||||
try {
|
||||
rmSync(ownedTempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,18 +162,19 @@ export interface RestrictingSidSet {
|
||||
* Create the write-restricted token with the mode-selected restricting list
|
||||
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
|
||||
* - read-only: [logon SID, EVERYONE]
|
||||
* - workspace-write: [logon SID, EVERYONE, orphan]
|
||||
* - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID]
|
||||
*
|
||||
* The logon SID + EVERYONE keep-alive group is shared by both modes: early
|
||||
* DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
|
||||
* pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY
|
||||
* pwsh crashes 0xE0434352) fails without them. The write SIDs join ONLY
|
||||
* workspace-write — read-only carries no write SID, so a standing grant ACE
|
||||
* from an earlier workspace-write period (a `/permission` mode downgrade, or
|
||||
* a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
|
||||
* pass-2 check grants only what the restricting list carries, keeping
|
||||
* read-only strictly zero-grant even with stale ACEs standing, while the
|
||||
* unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no
|
||||
* re-propagation). Authenticated Users is absent from BOTH lists: the WMI
|
||||
* pass-2 check grants only what the restricting list carries, keeping that
|
||||
* workspace grant inert under read-only while the unrevoked ACE keeps the
|
||||
* re-upgrade free (the grant's exact-ACE skip — no re-propagation).
|
||||
* Everyone's own ambient grants remain the documented partial boundary.
|
||||
* Authenticated Users is absent from BOTH lists: the WMI
|
||||
* namespace security check fails (0x80041003), so CIM is unavailable in
|
||||
* every confined mode, and the C:\-root tree-creation escape (standing
|
||||
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
|
||||
@@ -185,24 +186,25 @@ export interface RestrictingSidSet {
|
||||
* @param api - the binding table.
|
||||
* @param currentToken - the process token to restrict.
|
||||
* @param logonSid - the copied logon session SID.
|
||||
* @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only).
|
||||
* @param writeSids - the distinct write SIDs forming the workspace and
|
||||
* optional temp allowlists (workspace-write only; empty under read-only).
|
||||
* @param known - the well-known SIDs entering the restricting list.
|
||||
* @param mode - selects the restricting list (workspace-write adds the write SID).
|
||||
* @param mode - selects the restricting list (workspace-write adds the capability SIDs).
|
||||
* @returns the restricted token handle.
|
||||
*/
|
||||
export function createRestrictedToken(
|
||||
api: Win32Bindings,
|
||||
currentToken: NativePtr,
|
||||
logonSid: NativePtr,
|
||||
writeSid: NativePtr | undefined,
|
||||
writeSids: readonly NativePtr[],
|
||||
known: RestrictingSidSet,
|
||||
mode: 'read-only' | 'workspace-write',
|
||||
): NativePtr {
|
||||
const restrictingSids = buildRestrictingSids(mode === 'read-only'
|
||||
? [logonSid, known.world]
|
||||
: writeSid === undefined
|
||||
? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })()
|
||||
: [logonSid, known.world, writeSid])
|
||||
: writeSids.length === 0
|
||||
? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })()
|
||||
: [logonSid, known.world, ...writeSids])
|
||||
const tokenSlot = allocPtrSlot()
|
||||
const created = api.createRestrictedToken(
|
||||
currentToken,
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FILE_DELETE_CHILD = 0x0040
|
||||
// security boundary).
|
||||
/**
|
||||
* GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and
|
||||
* FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant
|
||||
* FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant
|
||||
* (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are
|
||||
* deliberately excluded: they would let the confined child take ownership or
|
||||
* rewrite DACLs.
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* once per session. The SID's power is defined solely by the ACEs that name
|
||||
* it (which exist only on the workspace tree and the session's private temp
|
||||
* directory), and only tokens minted for that workspace carry it — the SID
|
||||
* string itself is not a secret (the previous per-session SID was likewise
|
||||
* logged in the plain).
|
||||
* string itself is not a secret. Temporary directories use a separate,
|
||||
* per-directory identity from {@link tempWriteSid}; sharing the workspace
|
||||
* identity with temp would let sibling sessions write one another's temp
|
||||
* trees.
|
||||
*
|
||||
* The input MUST be the canonical workspace path (`realpathSync.native` on
|
||||
* Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
|
||||
@@ -26,7 +28,7 @@ import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
|
||||
* matching the orphan shape the token and ACE layers already carry).
|
||||
* matching the workspace-capability shape the token and ACE layers carry).
|
||||
* @param workspaceRoot - the canonical workspace path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
@@ -36,3 +38,17 @@ export function workspaceWriteSid(workspaceRoot: string): string {
|
||||
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
|
||||
return `S-1-4-${first}-${second}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one private temp directory's write SID. The random directory path
|
||||
* is the capability identity; a fixed third subauthority domain-separates
|
||||
* the result from every two-subauthority workspace SID.
|
||||
* @param tempDir - the private temp directory's absolute path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
export function tempWriteSid(tempDir: string): string {
|
||||
const digest = createHash('sha256').update('temp\0', 'utf8').update(tempDir, 'utf8').digest()
|
||||
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
|
||||
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
|
||||
return `S-1-4-${first}-${second}-1`
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* whose per-test lock file is removed in cleanup.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -120,7 +120,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const usersSid = sidFromString(api, 'S-1-5-32-545')
|
||||
const orphanSid = sidFromString(api, 'S-1-4-4242-1')
|
||||
const capabilitySid = sidFromString(api, 'S-1-4-4242-1')
|
||||
try {
|
||||
// Install one explicit ACE (Users + benign read mask) with the
|
||||
// package's own bindings, exactly like a pre-existing explicit DACL
|
||||
@@ -137,37 +137,37 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS)
|
||||
expect(isNullPtr(freed)).toBe(true)
|
||||
|
||||
grantWrite(api, dir, orphanSid)
|
||||
revokeWrite(api, dir, orphanSid)
|
||||
grantWrite(api, dir, capabilitySid)
|
||||
revokeWrite(api, dir, capabilitySid)
|
||||
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed
|
||||
} finally {
|
||||
if (!isNullPtr(usersSid)) api.localFree(usersSid)
|
||||
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
|
||||
if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid)
|
||||
}
|
||||
})
|
||||
|
||||
it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const orphanSid = sidFromString(api, 'S-1-4-4242-2')
|
||||
const capabilitySid = sidFromString(api, 'S-1-4-4242-2')
|
||||
const apply = vi.spyOn(api, 'setNamedSecurityInfoW')
|
||||
try {
|
||||
grantWrite(api, dir, orphanSid)
|
||||
grantWrite(api, dir, capabilitySid)
|
||||
expect(apply).toHaveBeenCalledTimes(1)
|
||||
// The exact ACE now stands (the per-session grant surviving from a
|
||||
// previous server lifetime): the second grant is a DACL read only.
|
||||
grantWrite(api, dir, orphanSid)
|
||||
grantWrite(api, dir, capabilitySid)
|
||||
expect(apply).toHaveBeenCalledTimes(1)
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1)
|
||||
revokeWrite(api, dir, orphanSid)
|
||||
revokeWrite(api, dir, capabilitySid)
|
||||
expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false)
|
||||
} finally {
|
||||
apply.mockRestore()
|
||||
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
|
||||
if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -192,21 +192,58 @@ describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
const api = await win32()
|
||||
const workspaceDir = scratch()
|
||||
const tempDir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceDir],
|
||||
tempDir,
|
||||
writeSid: 'S-1-4-9000-3',
|
||||
tempWriteSid: 'S-1-4-9000-3-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
sandbox.dispose()
|
||||
const workspaceAces = readDirectAces(api, workspaceDir)
|
||||
expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true)
|
||||
const tempAces = readDirectAces(api, tempDir)
|
||||
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false)
|
||||
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an overlapping private temp directory before applying either capability', async () => {
|
||||
const workspaceDir = scratch()
|
||||
const nestedTemp = join(workspaceDir, 'temp')
|
||||
const writeSid = 'S-1-4-9000-30'
|
||||
const privateTempSid = 'S-1-4-9000-30-1'
|
||||
mkdirSync(nestedTemp)
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspaceDir],
|
||||
tempDir: nestedTemp,
|
||||
writeSid,
|
||||
tempWriteSid: privateTempSid,
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
|
||||
await expect(sandbox.init()).rejects.toThrow(/private temp directory must be disjoint/u)
|
||||
const api = await win32()
|
||||
expect(readDirectAces(api, workspaceDir).some(ace => ace.sid === writeSid)).toBe(false)
|
||||
expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
|
||||
const dir = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))
|
||||
.toThrow(/requires a write SID/)
|
||||
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write'))
|
||||
.toThrow(/requires the write SID/)
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
|
||||
.toThrow(/requires an explicit private temp directory or null/)
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
|
||||
.toThrow(/requires a temp write SID/)
|
||||
expect(() => new AclSandbox({
|
||||
writableDirs: [dir],
|
||||
tempDir: dir,
|
||||
writeSid: 'S-1-4-1-1',
|
||||
tempWriteSid: 'S-1-4-1-1',
|
||||
mode: 'workspace-write',
|
||||
})).toThrow(/must be distinct/)
|
||||
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write'))
|
||||
.toThrow(/requires at least one write SID/)
|
||||
})
|
||||
|
||||
it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => {
|
||||
|
||||
@@ -59,8 +59,8 @@ function scratch(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stub the whole happy pipeline needs: token opening, write-SID parse,
|
||||
* workspace+temp grants, logon-SID scan, well-known SID, restricted token,
|
||||
* The stub the whole happy pipeline needs: token opening, capability-SID
|
||||
* parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token,
|
||||
* default-DACL merge, piped/inherited spawns, drains, and exit waits all
|
||||
* succeed. Every test flips one call per branch.
|
||||
*/
|
||||
@@ -186,6 +186,28 @@ describe('AclSandbox constructor validation', () => {
|
||||
expect(sandbox.mode).toBe('read-only')
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects temp authority under read-only', () => {
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept a temp directory/u)
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept write SIDs/u)
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' }))
|
||||
.toThrow(/read-only does not accept write SIDs/u)
|
||||
})
|
||||
|
||||
it('rejects a temp SID when temp writes are disabled', () => {
|
||||
const workspace = scratch()
|
||||
expect(() => new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: null,
|
||||
writeSid: 'S-1-4-9000-2',
|
||||
tempWriteSid: 'S-1-4-9000-2-1',
|
||||
mode: 'workspace-write',
|
||||
})).toThrow(/temp write SID requires a temp directory/u)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AclSandbox init', () => {
|
||||
@@ -193,17 +215,22 @@ describe('AclSandbox init', () => {
|
||||
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-1',
|
||||
tempWriteSid: 'S-1-4-9000-1-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
expect(sandbox.tempDir).toBe(resolve(temp))
|
||||
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => {
|
||||
it('requires an explicit private temp directory or null under workspace-write', () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, ''))
|
||||
expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
|
||||
.toThrow(/requires an explicit private temp directory or null/u)
|
||||
})
|
||||
|
||||
it('applies no grants when the temp dir option is null', async () => {
|
||||
@@ -216,7 +243,13 @@ describe('AclSandbox init', () => {
|
||||
|
||||
it('rejects a temp dir that does not exist', async () => {
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: join(scratch(), 'missing'),
|
||||
writeSid: 'S-1-4-9000-4',
|
||||
tempWriteSid: 'S-1-4-9000-4-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u)
|
||||
})
|
||||
|
||||
@@ -263,18 +296,31 @@ describe('AclSandbox init', () => {
|
||||
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
|
||||
})
|
||||
|
||||
it('reports a failed close of the current process token', async () => {
|
||||
const { closeHandle } = state.stubs as HappyStubs
|
||||
it('aggregates failed current and restricted token closes after init', async () => {
|
||||
const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
|
||||
const restrictedToken = 99n
|
||||
createRestrictedToken.mockImplementation((
|
||||
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
|
||||
_rc: unknown, _rs: unknown, slot: NativePtr,
|
||||
) => {
|
||||
koffi.encode(slot, PVOID, restrictedToken)
|
||||
return 1
|
||||
})
|
||||
// fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the
|
||||
// token-layer close of 1n succeeds and init's close of 2n fails.
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1))
|
||||
closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n || handle === restrictedToken ? 0 : 1))
|
||||
// The failure lands after this.token is stored but before this.api is
|
||||
// assigned; the catch drains the SID allocations and rethrows the
|
||||
// original error. (The stored restricted token and parsed write SID leak
|
||||
// until process exit — see the FIXME in init's catch.)
|
||||
await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' })
|
||||
// assigned. Cleanup retries the still-open handle and reports both close
|
||||
// failures plus the restricted-token close after releasing parsed SIDs.
|
||||
await expect(sandbox.init()).rejects.toMatchObject({
|
||||
errors: [
|
||||
{ api: 'CloseHandle' },
|
||||
{ api: 'CloseHandle' },
|
||||
{ api: 'CloseHandle' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => {
|
||||
@@ -296,8 +342,15 @@ describe('AclSandbox init', () => {
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
})
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' })
|
||||
await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u)
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-10',
|
||||
tempWriteSid: 'S-1-4-9000-10-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u)
|
||||
expect(sandbox.tempDir).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -354,7 +407,13 @@ describe('AclSandbox dispose', () => {
|
||||
const { getNamedSecurityInfoW } = state.stubs as HappyStubs
|
||||
const workspace = scratch()
|
||||
const temp = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' })
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: [workspace],
|
||||
tempDir: temp,
|
||||
writeSid: 'S-1-4-9000-16',
|
||||
tempWriteSid: 'S-1-4-9000-16-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
getNamedSecurityInfoW.mockReturnValue(2)
|
||||
expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Canonical path-overlap checks that keep workspace and temp capabilities separate. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from '../src/path-boundary.ts'
|
||||
|
||||
describe('Windows ACL temp path boundary', () => {
|
||||
const scratchDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-boundary-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
it('rejects a temp root equal to or below the workspace', () => {
|
||||
const workspace = scratch()
|
||||
const nested = join(workspace, 'temp')
|
||||
mkdirSync(nested)
|
||||
|
||||
expect(() => {
|
||||
assertTempRootOutsideWorkspace(workspace, workspace)
|
||||
}).toThrow(/temp root must be outside the workspace/u)
|
||||
expect(() => {
|
||||
assertTempRootOutsideWorkspace(workspace, nested)
|
||||
}).toThrow(/temp root must be outside the workspace/u)
|
||||
})
|
||||
|
||||
it('accepts a temp parent above the workspace because a fresh child is a sibling', () => {
|
||||
const tempRoot = scratch()
|
||||
const workspace = join(tempRoot, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
|
||||
expect(() => {
|
||||
assertTempRootOutsideWorkspace(workspace, tempRoot)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('requires an actual private temp directory to be disjoint in either direction', () => {
|
||||
const root = scratch()
|
||||
const workspace = join(root, 'workspace')
|
||||
const nestedTemp = join(workspace, 'temp')
|
||||
const siblingTemp = join(root, 'sibling-temp')
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(nestedTemp)
|
||||
mkdirSync(siblingTemp)
|
||||
|
||||
expect(() => {
|
||||
assertPrivateTempDisjoint([workspace], nestedTemp)
|
||||
}).toThrow(/must be disjoint/u)
|
||||
expect(() => {
|
||||
assertPrivateTempDisjoint([nestedTemp], workspace)
|
||||
}).toThrow(/must be disjoint/u)
|
||||
expect(() => {
|
||||
assertPrivateTempDisjoint([workspace], siblingTemp)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,10 @@
|
||||
* WRITE_RESTRICTED token intersects write accesses only.
|
||||
*
|
||||
* The escape target sits in its own scratch dir under the system temp
|
||||
* directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never
|
||||
* defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the
|
||||
* whole real temp tree) and the writable dir is a separate mkdtemp directory
|
||||
* that contains neither sibling. Nothing under the user profile is touched.
|
||||
* directory, OUTSIDE both granted trees: tempDir is an explicit private
|
||||
* mkdtemp directory (the API never grants the ambient temp root implicitly),
|
||||
* and the writable dir is a separate mkdtemp directory that contains neither
|
||||
* sibling. Nothing under the user profile is touched.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
@@ -47,11 +47,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
// tempDir is passed explicitly: GetTempPathW reads the native environment
|
||||
// block, which host runtimes (vitest worker pools) may not keep in sync
|
||||
// with process.env — and a real-temp grant would inherit over every
|
||||
// temp subdirectory, including this test's scratch dir.
|
||||
sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' })
|
||||
// The direct API requires this explicit private temp directory and its
|
||||
// own SID; it never widens the grant over the ambient temp root.
|
||||
sandbox = new AclSandbox({
|
||||
writableDirs: [writableDir],
|
||||
tempDir: isolatedTemp,
|
||||
writeSid: 'S-1-4-9000-4',
|
||||
tempWriteSid: 'S-1-4-9000-4-1',
|
||||
mode: 'workspace-write',
|
||||
})
|
||||
await sandbox.init()
|
||||
})
|
||||
|
||||
@@ -91,7 +95,16 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
|
||||
it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => {
|
||||
// A malformed SID makes ConvertStringSidToSidW fail; init must throw
|
||||
// before any grant is applied and never spawn unrestricted.
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
|
||||
}, 15_000)
|
||||
|
||||
it('failed init clears provisional temp state before a retry', async () => {
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
const provisionalState = broken as unknown as { tempDirResolved: string | undefined }
|
||||
provisionalState.tempDirResolved = isolatedTemp
|
||||
|
||||
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
|
||||
expect(broken.tempDir).toBeUndefined()
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) {
|
||||
}
|
||||
|
||||
describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => {
|
||||
it('agentless workspace-write: runner argv prefix, temp root, mode flag, partial enforcement, ACL denial dialect', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => true)
|
||||
const sandbox = await setup({
|
||||
platform: 'win32',
|
||||
@@ -41,7 +41,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
// A sole candidate is selected unprobed.
|
||||
@@ -52,7 +52,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.enforcement).toBe('partial')
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { AclWriteGrant } from '../src/index.ts'
|
||||
import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
|
||||
@@ -38,6 +38,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let worldWritableDir!: string
|
||||
// The ambient-writable probe target: a subdirectory of C:\Users\Public.
|
||||
// INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public
|
||||
// tree's INTERACTIVE grant must NOT satisfy the write check — the ambient
|
||||
@@ -54,6 +55,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
worldWritableDir = join(scratchRoot, 'world-writable')
|
||||
mkdirSync(worldWritableDir)
|
||||
const worldGrant = spawnSync('icacls', [worldWritableDir, '/grant', '*S-1-1-0:(OI)(CI)(M)'], { encoding: 'utf8' })
|
||||
if (worldGrant.status !== 0) {
|
||||
throw new Error(`icacls Everyone grant failed: ${worldGrant.stdout}\n${worldGrant.stderr}`)
|
||||
}
|
||||
try {
|
||||
publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-'))
|
||||
} catch {
|
||||
@@ -70,12 +77,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
it('workspace-write: the confined child writes granted directories only', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
// The restricted token puts pwsh into ConstrainedLanguage in BOTH modes
|
||||
// (documented Known Limitation) — pinned here so a token change that
|
||||
// silently restores FullLanguage is caught.
|
||||
// The private-temp capability lets PowerShell complete its startup
|
||||
// AppLocker probe, so without a host policy workspace-write stays in
|
||||
// FullLanguage. Read-only cannot create those scratch files and fails
|
||||
// that probe closed to ConstrainedLanguage (pinned below).
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
"try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
// Authenticated Users is absent from BOTH lists: the WMI namespace
|
||||
@@ -89,7 +97,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
|
||||
expect(result.stdout).toContain('LANGMODE: FullLanguage')
|
||||
expect(result.stdout).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout).toContain('ESCAPE-WRITE: DENIED')
|
||||
@@ -99,13 +107,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => {
|
||||
it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM unavailable', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
// The NUL device is a securable object: strict zero grants deny it too.
|
||||
// Set-Content NUL fails at the PowerShell/.NET layer even though the
|
||||
// device DACL's Everyone rights remain an ambient backend boundary.
|
||||
'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};',
|
||||
// PowerShell's $null redirection discards without opening NUL — must keep working.
|
||||
'echo hi > $null;\'DOLLAR-NULL: OK\';',
|
||||
@@ -155,33 +164,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
expect(existsSync(renamedDir)).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => {
|
||||
const writeSid = 'S-1-4-9000-99'
|
||||
it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => {
|
||||
const seamWorkspace = join(scratchRoot, 'seam-workspace')
|
||||
mkdirSync(seamWorkspace)
|
||||
const writeSid = workspaceWriteSid(seamWorkspace)
|
||||
const privateTemp = join(isolatedTemp, 'private-subdir')
|
||||
mkdirSync(privateTemp)
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
const privateTempSid = tempWriteSid(privateTemp)
|
||||
const grant = AclWriteGrant.create(privateTempSid)
|
||||
grant.add(privateTemp)
|
||||
try {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${seamWorkspace}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`,
|
||||
"'TEMP-ENV: ' + $env:TEMP;",
|
||||
"'TMP-ENV: ' + $env:TMP",
|
||||
].join('')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--workspace', seamWorkspace, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--temp-write-sid', privateTempSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
// The runner granted nothing (only the caller's private-temp grant
|
||||
// The runner granted nothing (only the caller's temp-SID grant
|
||||
// stands): the workspace write is denied, the private temp write lands,
|
||||
// and the child's TMP/TEMP point at the private subdirectory.
|
||||
expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK')
|
||||
expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`)
|
||||
expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`)
|
||||
expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false)
|
||||
expect(existsSync(join(seamWorkspace, 'server-granted.txt'))).toBe(false)
|
||||
expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true)
|
||||
} finally {
|
||||
grant.dispose()
|
||||
@@ -189,6 +202,97 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('temp capabilities isolate sibling sessions that share one workspace SID', () => {
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const tempA = join(isolatedTemp, 'session-a')
|
||||
const tempB = join(isolatedTemp, 'session-b')
|
||||
mkdirSync(tempA)
|
||||
mkdirSync(tempB)
|
||||
const sidA = tempWriteSid(tempA)
|
||||
const sidB = tempWriteSid(tempB)
|
||||
const workspaceGrant = AclWriteGrant.create(writeSid)
|
||||
const grantA = AclWriteGrant.create(sidA)
|
||||
const grantB = AclWriteGrant.create(sidB)
|
||||
workspaceGrant.add(writableDir)
|
||||
grantA.add(tempA)
|
||||
grantB.add(tempB)
|
||||
const sharedWorkspaceFile = join(writableDir, 'shared-between-sessions.txt')
|
||||
const probe = [
|
||||
"const fs = require('node:fs');",
|
||||
"const targets = [['OWN', process.argv[1]], ['SIBLING', process.argv[2]], ['WORKSPACE', process.argv[3]]];",
|
||||
"if (process.argv[4]) targets.push(['SIBLING-EXISTING', process.argv[4]]);",
|
||||
'for (const [name, target] of targets) {',
|
||||
"try { fs.writeFileSync(target, name); console.log(name + ': OK'); } catch { console.log(name + ': DENIED'); }",
|
||||
'}',
|
||||
].join('')
|
||||
try {
|
||||
const resultA = runRunner([
|
||||
'--workspace', writableDir, '--temp', tempA, '--mode', 'workspace-write',
|
||||
'--write-sid', writeSid, '--temp-write-sid', sidA,
|
||||
'--', process.execPath, '-e', probe, join(tempA, 'a.txt'), join(tempB, 'a-escaped.txt'), sharedWorkspaceFile,
|
||||
])
|
||||
expect(resultA.status, `stderr: ${resultA.stderr}`).toBe(0)
|
||||
expect(resultA.stdout).toContain('OWN: OK')
|
||||
expect(resultA.stdout).toContain('SIBLING: DENIED')
|
||||
expect(resultA.stdout).toContain('WORKSPACE: OK')
|
||||
|
||||
const resultB = runRunner([
|
||||
'--workspace', writableDir, '--temp', tempB, '--mode', 'workspace-write',
|
||||
'--write-sid', writeSid, '--temp-write-sid', sidB,
|
||||
'--', process.execPath, '-e', probe, join(tempB, 'b.txt'), join(tempA, 'b-escaped.txt'), sharedWorkspaceFile, join(tempA, 'a.txt'),
|
||||
])
|
||||
expect(resultB.status, `stderr: ${resultB.stderr}`).toBe(0)
|
||||
expect(resultB.stdout).toContain('OWN: OK')
|
||||
expect(resultB.stdout).toContain('SIBLING: DENIED')
|
||||
expect(resultB.stdout).toContain('SIBLING-EXISTING: DENIED')
|
||||
expect(resultB.stdout).toContain('WORKSPACE: OK')
|
||||
expect(existsSync(join(tempB, 'a-escaped.txt'))).toBe(false)
|
||||
expect(existsSync(join(tempA, 'b-escaped.txt'))).toBe(false)
|
||||
expect(readFileSync(join(tempA, 'a.txt'), 'utf8')).toBe('OWN')
|
||||
} finally {
|
||||
workspaceGrant.dispose()
|
||||
grantA.dispose()
|
||||
grantB.dispose()
|
||||
rmSync(tempA, { recursive: true, force: true })
|
||||
rmSync(tempB, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('agentless workspace-write creates a fresh private temp per call and removes it on exit', () => {
|
||||
const captureA = join(writableDir, 'agentless-temp-a.txt')
|
||||
const captureB = join(writableDir, 'agentless-temp-b.txt')
|
||||
for (const capture of [captureA, captureB]) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], process.env.TEMP)", capture,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
}
|
||||
const tempA = readFileSync(captureA, 'utf8')
|
||||
const tempB = readFileSync(captureB, 'utf8')
|
||||
expect(tempA).not.toBe(tempB)
|
||||
expect(tempA.startsWith(isolatedTemp)).toBe(true)
|
||||
expect(tempB.startsWith(isolatedTemp)).toBe(true)
|
||||
expect(existsSync(tempA)).toBe(false)
|
||||
expect(existsSync(tempB)).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
it('agentless workspace-write rejects a temp root inside the workspace before spawning', () => {
|
||||
const overlapWorkspace = join(scratchRoot, 'overlap-workspace')
|
||||
const nestedTempRoot = join(overlapWorkspace, 'temp')
|
||||
const marker = join(overlapWorkspace, 'command-ran.txt')
|
||||
mkdirSync(overlapWorkspace)
|
||||
mkdirSync(nestedTempRoot)
|
||||
|
||||
const result = runRunner([
|
||||
'--workspace', overlapWorkspace, '--temp', nestedTempRoot, '--mode', 'workspace-write',
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran')", marker,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(127)
|
||||
expect(result.stderr).toContain('windows-acl-run: Windows ACL temp root must be outside the workspace')
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
}, 15_000)
|
||||
|
||||
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
|
||||
// Two-layer pin of the grandchild-spawn boundary:
|
||||
// - the token default DACL carries a restricting-SID ACE (set in init),
|
||||
@@ -226,11 +330,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
// The reported defect: a session that materialized its grant in
|
||||
// workspace-write keeps the ACE standing for the server lifetime. After
|
||||
// switching to read-only, the restricted token's read-only list must carry NO
|
||||
// orphan SID — the standing ACE stays but the pass-2 check cannot use
|
||||
// capability SID — the standing ACE stays but the pass-2 check cannot use
|
||||
// it, so the workspace write is denied (previously it LEAKED). The
|
||||
// switch back reuses the SAME standing ACE: the re-upgrade write lands
|
||||
// without any re-grant.
|
||||
const writeSid = 'S-1-4-9001-7'
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const privateTemp = join(isolatedTemp, 'mode-switch-temp')
|
||||
mkdirSync(privateTemp)
|
||||
const privateTempSid = tempWriteSid(privateTemp)
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
grant.add(writableDir)
|
||||
try {
|
||||
@@ -239,7 +346,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
`try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const downgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid,
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe,
|
||||
])
|
||||
expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0)
|
||||
@@ -251,7 +358,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
`try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const reupgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--temp-write-sid', privateTempSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe,
|
||||
])
|
||||
expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0)
|
||||
@@ -259,6 +367,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true)
|
||||
} finally {
|
||||
grant.dispose()
|
||||
rmSync(privateTemp, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
@@ -286,9 +395,67 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => {
|
||||
// Everyone is a required keep-alive restricting SID: without it early DLL
|
||||
// initialization and CNG fail. A normal DACL that grants Everyone Modify
|
||||
// therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this
|
||||
// unavoidable gap beside the provider's `partial` enforcement report.
|
||||
for (const mode of ['read-only', 'workspace-write'] as const) {
|
||||
const target = join(worldWritableDir, `${mode}.txt`)
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target,
|
||||
])
|
||||
expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0)
|
||||
expect(existsSync(target), `mode: ${mode}`).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('partial boundary: a workspace hard link lets the grant reach an external file object', () => {
|
||||
// NTFS ACLs belong to the file object, not one pathname. Propagating the
|
||||
// workspace write-SID ACE through an existing hard-link alias therefore
|
||||
// grants the external alias too. pnpm workspaces commonly contain hard
|
||||
// links, so rejecting every multiply-linked file is not a viable profile.
|
||||
const hardlinkWorkspace = join(scratchRoot, 'hardlink-workspace')
|
||||
const hardlinkTemp = join(scratchRoot, 'hardlink-temp')
|
||||
const externalFile = join(scratchRoot, 'hardlink-target.txt')
|
||||
const workspaceLink = join(hardlinkWorkspace, 'hardlink-alias.txt')
|
||||
mkdirSync(hardlinkWorkspace)
|
||||
mkdirSync(hardlinkTemp)
|
||||
writeFileSync(externalFile, 'original')
|
||||
linkSync(externalFile, workspaceLink)
|
||||
const result = runRunner([
|
||||
// This workspace has not been granted before the alias exists: the first
|
||||
// recursive materialization reaches the shared file security descriptor.
|
||||
'--workspace', hardlinkWorkspace, '--temp', hardlinkTemp, '--mode', 'workspace-write',
|
||||
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'mutated')", workspaceLink,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(readFileSync(externalFile, 'utf8')).toBe('mutated')
|
||||
}, 30_000)
|
||||
|
||||
it('runner-side failure: signature on stderr and exit 127, the command never runs', () => {
|
||||
const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write'])
|
||||
expect(result.status).toBe(127)
|
||||
expect(result.stderr).toContain('windows-acl-run: ')
|
||||
}, 15_000)
|
||||
|
||||
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
|
||||
const writeSid = workspaceWriteSid(writableDir)
|
||||
const tempSid = tempWriteSid(isolatedTemp)
|
||||
const cases = [
|
||||
['--write-sid', writeSid],
|
||||
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
|
||||
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
|
||||
]
|
||||
for (const args of cases) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
...args,
|
||||
'--', process.execPath, '-e', 'process.exit(99)',
|
||||
])
|
||||
expect(result.status, `args: ${args.join(' ')}\nstderr: ${result.stderr}`).toBe(127)
|
||||
expect(result.stderr).toContain('windows-acl-run: ')
|
||||
}
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('createRestrictedToken failure paths', () => {
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')).toBe(9n)
|
||||
})
|
||||
|
||||
it('builds the workspace-write restricting list with the write SID', () => {
|
||||
@@ -397,7 +397,7 @@ describe('createRestrictedToken failure paths', () => {
|
||||
})
|
||||
const api = { createRestrictedToken: create } as unknown as Win32Bindings
|
||||
const logon = allocBytes(12)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n)
|
||||
expect(createRestrictedToken(api, 1n as NativePtr, logon, [3n as NativePtr], { world: 2n as NativePtr }, 'workspace-write')).toBe(9n)
|
||||
})
|
||||
|
||||
it('reports when CreateRestrictedToken fails', () => {
|
||||
@@ -409,7 +409,7 @@ describe('createRestrictedToken failure paths', () => {
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
@@ -426,7 +426,7 @@ describe('createRestrictedToken failure paths', () => {
|
||||
const logon = allocBytes(12)
|
||||
let caught: unknown
|
||||
try {
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')
|
||||
createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* workspaceWriteSid tests: the per-workspace write identity is deterministic
|
||||
* (the same canonical path always derives the same SID — the property the
|
||||
* cross-session grant reuse rests on), orphan-shaped, distinct across
|
||||
* cross-session grant reuse rests on), capability-shaped, distinct across
|
||||
* workspaces, and byte-sensitive (the canonical path is the caller's
|
||||
* contract; an alias spelling derives a second identity, self-healing at
|
||||
* the cost of one extra tree propagation).
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { workspaceWriteSid } from '../src/index.ts'
|
||||
import { tempWriteSid, workspaceWriteSid } from '../src/index.ts'
|
||||
|
||||
describe('workspaceWriteSid', () => {
|
||||
it('derives a stable orphan-shaped SID per workspace path', () => {
|
||||
it('derives a stable capability-shaped SID per workspace path', () => {
|
||||
const first = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
const second = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
expect(first).toBe(second)
|
||||
@@ -28,3 +28,16 @@ describe('workspaceWriteSid', () => {
|
||||
expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('tempWriteSid', () => {
|
||||
it('derives a stable domain-separated SID per private temp path', () => {
|
||||
const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')
|
||||
expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
|
||||
expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u)
|
||||
expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
|
||||
})
|
||||
|
||||
it('derives distinct capabilities for distinct private temp paths', () => {
|
||||
expect(tempWriteSid('C:\\Temp\\dsh-a')).not.toBe(tempWriteSid('C:\\Temp\\dsh-b'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,10 +43,10 @@ export interface SandboxExecutionPolicy {
|
||||
workspaceRoot: string
|
||||
/**
|
||||
* Opaque identity of the calling session (the branded `dsh-session`
|
||||
* SessionId). Backends key per-session state off it (e.g. the windows-acl
|
||||
* per-session private temp subdirectory — the write grant itself is
|
||||
* per-workspace, derived from the workspace root); absent for agentless
|
||||
* calls, which fall back to per-call backend state.
|
||||
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
|
||||
* each live session/workspace pair a random private temp directory and SID,
|
||||
* while the workspace SID and standing grant remain per-workspace); absent
|
||||
* for agentless calls, which fall back to per-call backend state.
|
||||
*/
|
||||
sessionId?: SessionId
|
||||
}
|
||||
@@ -156,7 +156,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
* skipped for a sole candidate, whose own refusal remains the fail-closed end.
|
||||
*/
|
||||
export abstract class SandboxProvider extends Service {
|
||||
/* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */
|
||||
/* v8 ignore next -- abstract service construction is covered through concrete provider packages. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sandbox')
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -41,7 +41,7 @@ afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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-fork/README.md
|
||||
README.md: 55475aee7841e91960de79887dfe9bf37afdf9da
|
||||
README.zh.md: 379218970c406162309659277a4fcb2da885d9ea
|
||||
README.md: 2bd72058ab7d112f8f317a33842fc4d95b719017
|
||||
README.zh.md: 40f9e34c5a8ae8c74de2ae0c9e4676866f0bd083
|
||||
|
||||
@@ -39,7 +39,7 @@ Forking duplicates retained completed history into separate child requests; the
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only.
|
||||
The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)).
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
@@ -58,3 +58,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.
|
||||
- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md).
|
||||
|
||||
@@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。
|
||||
在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md))。
|
||||
|
||||
### 父 agent 工具结果(间接)
|
||||
|
||||
@@ -58,3 +58,4 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。
|
||||
- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)。
|
||||
|
||||
@@ -74,6 +74,12 @@ class ForkProvider implements SubagentProvider {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(fork-continuable-prefix-reuse): no shipped composition calls this —
|
||||
// they bind fork to `backgroundMode: one-shot` because a continuable child's
|
||||
// `report` tool and prompt section precede the inherited history, defeating
|
||||
// the prefix reuse a fork exists for. Reopening needs a byte-identical child
|
||||
// system prompt and tool schemas; see issue #2124 and
|
||||
// .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
prepareContinuable(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec> {
|
||||
// The fork prefix is captured ONCE, at creation: it becomes part of the
|
||||
// child's own durable transcript, so a later cold resume replays that
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
appendDelegatedPolicyOverrides,
|
||||
@@ -52,6 +53,10 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
// A pre-step rejection discarded the claimed prompt: the task was
|
||||
// declined, and the caller must not read the run as done.
|
||||
case 'blocked':
|
||||
return 'refusal'
|
||||
case 'error':
|
||||
case 'interrupted':
|
||||
default:
|
||||
@@ -207,7 +212,11 @@ function readResult(
|
||||
structured?: { captured?: { value: unknown } | undefined },
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(boundary)
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
// `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its
|
||||
// awaited first turn almost immediately, and the owner's own teardown is the
|
||||
// `cancelled` flag below. A cancellation with no accounting turn resolves
|
||||
// `error` through `toStopReason(undefined)`, which never overstates success.
|
||||
const lastEnd = foldConsumedWork(own).end
|
||||
// The seam's canonical selection rule; a partial answer survives cancel and truncation.
|
||||
const output: ContentBlock[] = finalAssistantOutput(own) ?? []
|
||||
const recorded = toStopReason(lastEnd?.data.reason)
|
||||
|
||||
@@ -82,6 +82,20 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('reports a prompt a pre-step rejection discarded as refusal, not completion', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
|
||||
// the rejection discards it, and the turn closes `blocked` with no step.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(request(parent), {})
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('does not add a final durability checkpoint to a foreground run', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: b9f65befab71edc0685f1a6cca767c803afcb76c
|
||||
README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3
|
||||
README.md: 27b01188cc1663d9cf59d3d24c91b3dbb35bea5a
|
||||
README.zh.md: e28a8794cfb227dd475ea2b4c42605a90ec7fa1d
|
||||
|
||||
@@ -76,6 +76,14 @@ The manager derives three internal residency conditions from Agent quiescence an
|
||||
|
||||
The manager reserves the child identity, resolves the durable descriptor, calls `ctx.agents.create()` (or `ctx.agents.resume()` for cold resume) through a private activation-owner scope, installs the returned `AgentHandle` in the Activation, establishes any continuable-parent ownership, and then submits the prompt. Cold resume never dispatches through a provider because the persisted Session already holds the initial prefix and the folded descriptor is the whole reconstruction input.
|
||||
|
||||
### Settlement delivery
|
||||
|
||||
When a resident Activation settles, the manager tells the child's durable direct parent, in the parent's own turn stream, that the child produced everything it is going to. Delivery is unconditional for every child whose id a caller actually received: it does not consider whether the child called `report`, because the endings that most need an account — a token ceiling, a model failure, cancellation, teardown — are exactly the ones where the child never got to choose. A materialization rolled back before its first accepted message stays silent, since that caller was told the child was not established. The message carries the epoch's stop reason, its final assistant content when it produced any, and durable provenance `{ kind: 'subagent-settled', form: 'notice', senderSessionId: <child-id> }` — a different source kind from a child-authored `subagent-report`, so a transcript never credits the child with words the runtime wrote.
|
||||
|
||||
Two ordering rules make the delivery reliable rather than lucky, and both are why this belongs to the manager instead of an external `subagent/end` listener. First, the send happens **before** the child's ownership release, while the parent still counts the child and is therefore structurally unable to be judged settled. Second, a parent that is itself a resident Activation receives the message through the same waking-admission accounting as a report, so the window between the synchronous send and the microtask that admits it is not mistaken for quiescence — `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake. Without either rule the parent can be disposed with the notice still in an inbox that `cancel()` clears, which loses it silently.
|
||||
|
||||
An idle parent receives the notice as one ordinary later turn. A busy parent is steered into its nearest step boundary instead, so several children settling together cost one step rather than one turn each; steering rather than injecting also means a driver that retires between the status read and the send still claims the message. A parent whose own lineage is already draining receives the notice by injection, with no wake at all: `Agent.followup()` on a quiescent parent starts a turn and `cancel()` does not arm against a later one, so waking during teardown would spend a model request on an Agent its host is about to dispose — once per tree layer, since each layer's notice then wakes the layer above it. The injected message reaches a parent that is still reading its inbox, and the log records the account either way, but it does not outlive that parent's own disposal: `AgentHandle.dispose()` is a `keepInbox: false` cancel, which durably cancels an unclaimed notice. A resumed parent therefore has no pending notice to read: `list_agents` tells it which children exist and whether each is live or stored, while the outcome itself stays in the child's own Session, which a `send_message` reaches by resuming that child. A parent that has left the registry is not an error: the notice is dropped and the child's own Session remains the durable record. Delivery never blocks or fails teardown — a rejected send is logged, because retaining a child to retry a notice would pin its whole ancestry in `waiting` forever.
|
||||
|
||||
A continuation-managed parent Activation records each child Session id in an `ownedChildren` set before the child can run and disposes only after every owned child Activation completes `AgentHandle` disposal (child-first). Teardown propagates Agent cancellation top-down before awaiting slow descendants, while handle release remains child-first. Top-level and other non-continuation Agents have no Activation and stay outside this waiting graph. Final settlement awaits a best-effort `ctx.sessions.flush(child.session)` before handle disposal. A listener rejection is logged without failing the Activation because listener participation does not identify a persistence backend; the persisted state may therefore be missing or stale on resume.
|
||||
|
||||
## Lifecycle events
|
||||
@@ -94,17 +102,31 @@ When `ctx.sessionProjections` is available, the service registers two projection
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry (`running`/`idle`/`complete`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Settlement notice
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One user-role parent message opening with the outcome — `Background subagent <child-id> finished and will do no further work unless you send it more.`, or the matching line for a child that was stopped, ran out of room, declined, or failed — followed by `Its closing message:` and the child's final assistant content, or `It left no closing message.` when it produced none. This is the service's only direct parent-side contribution; delegation schemas, parent continuation and discovery, and the child-scoped `report` belong to `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One notice per settled Activation in the parent's request, sized by the child's final message. A child that both reports and settles costs the parent both.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only in the parent: the notice follows its reusable request prefix. Reaching an idle parent starts one independent model request; reaching a busy one does not.
|
||||
|
||||
### Child delegation-scope statement
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`).
|
||||
Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences.
|
||||
|
||||
##### The delegation-scope statement
|
||||
|
||||
|
||||
@@ -76,6 +76,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
管理器预留子 agent 身份、解析持久化描述符,通过私有的 activation-owner 作用域调用 `ctx.agents.create()`(冷恢复时为 `ctx.agents.resume()`),把返回的 `AgentHandle` 安装到 Activation 中,建立任何可继续父级所有权,然后提交提示词。冷恢复绝不通过提供方分发,因为持久化 Session 已持有初始前缀,折叠后的描述符即是全部重建输入。
|
||||
|
||||
### 结算投递
|
||||
|
||||
当一个驻留 Activation 结算时,管理器会在父级自身的轮次流中告知该子级持久化的直接父级:这个子级已经产出它将产出的全部内容。对每个调用方真正拿到过 id 的子级,这条投递都是无条件的:它不考虑该子级是否调用过 `report`,因为最需要这条投递的结束方式——token 上限、模型失败、取消、拆卸——恰恰是子级根本没有机会选择的那些。在第一条消息被接受之前就回滚的物化保持静默,因为那位调用方已被告知该子级未建立。消息会携带该 epoch 的终止原因、它产出过的最终 assistant 内容,以及持久化来源 `{ kind: 'subagent-settled', form: 'notice', senderSessionId: <child-id> }`——与子级自撰的 `subagent-report` 是不同的来源 kind,因此 transcript(文本记录)绝不会把运行时写下的话算到子级头上。
|
||||
|
||||
有两条顺序规则让这条投递可靠而非侥幸,它们也正是这件事属于管理器而非外部 `subagent/end` listener 的原因。第一,发送发生在子级所有权释放**之前**,此时父级仍然计入该子级,因此在结构上不可能被判定为已结算。第二,本身就是驻留 Activation 的父级会通过与 report 相同的唤醒准入记账接收该消息,因此同步发送与承认它的那个 microtask 之间的窗口不会被误判为静止——`Agent.status` 会把上下文维护折叠成 `idle`,而维护期间的唤醒发送只会预置一次延后唤醒。缺少其中任一条规则,父级都可能在通知仍留在 inbox 时被 dispose,而 `cancel()` 会清空该 inbox,于是通知被静默丢失。
|
||||
|
||||
空闲父级会以一个普通的后续轮次收到该通知。繁忙父级则被 steer 到其最近的 step 边界,因此同时结算的多个子级只消耗一个 step,而不是各自一个轮次;采用 steer 而非 inject 还意味着:即便驱动在状态读取与发送之间退出,该消息仍会被认领。若父级自身所在的谱系已在 draining,则该通知改为 inject 送达,完全不唤醒:对静息父级调用 `Agent.followup()` 会开启一个轮次,而 `cancel()` 不会对之后的轮次设防,因此在拆卸期间唤醒,会在宿主即将 dispose 的 Agent 上白花一次模型请求——而且每层树各一次,因为每层自己的通知又会唤醒它上面那层。被 inject 的消息会送达仍在读取自身 inbox 的父级,而无论如何日志都会记录这份记账;但它不会比该父级自身的 dispose 活得更久:`AgentHandle.dispose()` 是一次 `keepInbox: false` 的 cancel,会持久地取消尚未被认领的通知。因此 resume 后的父级没有待处理通知可读:`list_agents` 只告诉它有哪些子级、各自是在线还是仅存于存储;结局本身留在子级自己的 Session 里,一次 `send_message` 会通过 resume 该子级把它取回。已离开注册表的父级不算错误:通知被丢弃,子级自身的 Session 仍是持久记录。投递绝不会阻塞或使拆卸失败——发送被拒只会记录日志,因为为了重试一条通知而保留子级,会把它的整条祖先链永久钉在 `waiting` 上。
|
||||
|
||||
受继续执行管理的父级 Activation 会在子 agent 能够运行之前,把每个子 agent 的 Session id 记录到 `ownedChildren` 集合中,并且只有在每个所拥有的子 agent Activation 完成 `AgentHandle` dispose 之后才会 dispose(子先于父)。拆卸会先自顶向下传播 Agent 取消,再等待缓慢的后代,而 handle 释放仍保持 child-first。顶层及其他非继续执行的 Agent 没有 Activation,处于该等待图之外。最终结算会在 dispose handle 前等待 best-effort 的 `ctx.sessions.flush(child.session)`。listener rejection 会被记录,但不会使 Activation 失败,因为 listener 是否参与无法标识持久化后端;因此,恢复时持久化状态可能缺失或陈旧。
|
||||
|
||||
## 生命周期事件
|
||||
@@ -94,17 +102,31 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 结算通知
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
一条用户角色的父级消息,开头是结果本身——`Background subagent <child-id> finished and will do no further work unless you send it more.`,或子级被停止、耗尽额度、拒绝任务或失败时的对应句子——随后是 `Its closing message:` 与子级的最终 assistant 内容;若子级没有产出内容,则是 `It left no closing message.`。这是本服务面向父级的唯一直接贡献;委派 schema、父级延续与发现以及子级作用域的 `report` 分别归 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 所有。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
父级请求中,每个已结算的 Activation 一条通知,长度取决于子级的最终消息。既上报又结算的子级会让父级同时支付两份。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在父级中仅追加:通知位于其可复用请求前缀之后。到达空闲父级会启动一次独立的模型请求,到达繁忙父级则不会。
|
||||
|
||||
### 子级委派范围声明
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。
|
||||
每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后。
|
||||
|
||||
##### 委派范围声明
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Internal continuable-subagent manager: stable child ids, descriptor
|
||||
* persistence, activation admission, the live ownership graph, cold resume,
|
||||
* and child-first disposal behind `ctx.subagents`.
|
||||
* child-first disposal, and settlement delivery to the parent, behind
|
||||
* `ctx.subagents`.
|
||||
*
|
||||
* A continuable child has one durable Session and at most one process-local
|
||||
* {@link Activation} — one residency epoch for a reconstructed child Agent. An
|
||||
@@ -11,6 +12,12 @@
|
||||
* residency while the Agent loop owns all turn ordering and execution. No
|
||||
* continuable path creates a Task or an intermediate result-bearing wrapper.
|
||||
*
|
||||
* Because residency is this manager's alone to end, telling the parent that a
|
||||
* child settled is its job too. An external `subagent/end` listener cannot do
|
||||
* it correctly: that payload names no parent, the child handle is already
|
||||
* disposed by then, and the release that wakes the parent's own settlement
|
||||
* watcher has already run. See {@link SubagentContinuationManager.notifySettlement}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
@@ -23,7 +30,7 @@ import type {
|
||||
AgentSetupCommit,
|
||||
CreateAgentOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -42,8 +49,8 @@ import {
|
||||
import type { DelegatedPolicyOverrides } from './child-agent.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver } from './lifecycle.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentResult, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
|
||||
|
||||
@@ -65,10 +72,28 @@ export interface SubagentReportMessageSource {
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable attribution for the runtime's own account of a continuable child
|
||||
* settling. Deliberately a different kind from
|
||||
* {@link SubagentReportMessageSource}: a report is content the child chose,
|
||||
* while this message is the manager stating what became of the child, and a
|
||||
* transcript that merged them would credit the child with words it never wrote.
|
||||
*/
|
||||
export interface SubagentSettledMessageSource {
|
||||
readonly kind: 'subagent-settled'
|
||||
/** A runtime account shown without expanding the row (`notice` context form). */
|
||||
readonly form: 'notice'
|
||||
/** One-line account of how the child ended. */
|
||||
readonly summary: string
|
||||
/** Session id of the child that settled. */
|
||||
readonly senderSessionId: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
coordinator: CoordinatorMessageSource
|
||||
'subagent-report': SubagentReportMessageSource
|
||||
'subagent-settled': SubagentSettledMessageSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +191,13 @@ interface ContinuationHost {
|
||||
interface Activation {
|
||||
/** The durable child this Activation is an epoch of. */
|
||||
readonly childId: SessionId
|
||||
/**
|
||||
* The durable direct parent, stored because settlement delivery must resolve
|
||||
* that parent after the child handle is gone. {@link ancestry} cannot answer
|
||||
* it: a `WeakSet` is not enumerable, and the child's own header is only
|
||||
* reachable through a handle disposal has already released.
|
||||
*/
|
||||
readonly parentSession: SessionId
|
||||
/** The provider name recorded in the durable descriptor. */
|
||||
readonly provider: string
|
||||
/** The retained live Agent handle, disposed exactly once at settlement. */
|
||||
@@ -197,6 +229,12 @@ interface Activation {
|
||||
* microtask that admits it, so settlement must not treat that gap as quiet.
|
||||
*/
|
||||
readonly accepted: Set<MessageId>
|
||||
/**
|
||||
* Whether any delivery to this child was ever accepted. A materialization
|
||||
* rolled back before its first acceptance is a child the caller was told does
|
||||
* not exist, so its teardown owes the parent no settlement account.
|
||||
*/
|
||||
announced: boolean
|
||||
/** Renewed whenever a settlement watcher must re-observe quiescence. */
|
||||
poke: PromiseWithResolvers<void>
|
||||
}
|
||||
@@ -243,6 +281,36 @@ function disposalOf(activation: Activation): Promise<void> | undefined {
|
||||
return activation.disposal
|
||||
}
|
||||
|
||||
/**
|
||||
* One line telling a parent that a background child is finished and why, in
|
||||
* the parent's own task vocabulary.
|
||||
* @param childId - the durable child the parent knows by id.
|
||||
* @param stopReason - how the child's last ordinary turn ended.
|
||||
* @returns the model-facing opening line of the settlement notice.
|
||||
*/
|
||||
function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string {
|
||||
const subject = `Background subagent ${childId}`
|
||||
switch (stopReason) {
|
||||
case 'completed':
|
||||
return `${subject} finished and will do no further work unless you send it more.`
|
||||
case 'aborted':
|
||||
return `${subject} was stopped before it finished.`
|
||||
case 'max-tokens':
|
||||
return `${subject} ran out of room before it finished.`
|
||||
// A pre-step rejection — a hook deny, a policy plugin — discarded input
|
||||
// the child had claimed, so the parent must not treat the task as done.
|
||||
case 'refusal':
|
||||
return `${subject} declined the task.`
|
||||
case 'error':
|
||||
return `${subject} failed before it finished.`
|
||||
/* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
|
||||
* needs a backend that adds a variant; an unnameable ending is reported as unfinished
|
||||
* rather than silently as success. */
|
||||
default:
|
||||
return `${subject} ended abnormally (${String(stopReason)}) before it finished.`
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one settlement attempt opened the disposal transaction. */
|
||||
type SettlementAttempt =
|
||||
| { readonly settling: false }
|
||||
@@ -576,19 +644,36 @@ export class SubagentContinuationManager {
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (delivery === 'wakeup'
|
||||
&& parentActivation !== undefined
|
||||
&& parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, () => {
|
||||
this.sendReport(parent, message, delivery)
|
||||
})
|
||||
if (delivery === 'wakeup') {
|
||||
this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) })
|
||||
} else {
|
||||
this.sendReport(parent, message, delivery)
|
||||
}
|
||||
return message.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one waking send to a parent, accounted against that parent's own
|
||||
* Activation when it has one. Registering the id before the send is what
|
||||
* keeps a continuation-managed parent from being judged quiescent in the
|
||||
* window between `followup()` and the microtask that admits it.
|
||||
* @param parent - the exact live parent receiving the waking message.
|
||||
* @param message - the message whose id is accounted.
|
||||
* @param send - the synchronous waking send to perform.
|
||||
*/
|
||||
private sendWaking(
|
||||
parent: Agent,
|
||||
message: ReturnType<typeof createUserMessage>,
|
||||
send: () => void,
|
||||
): void {
|
||||
const parentActivation = this.activations.get(parent.id)
|
||||
if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
|
||||
this.admitWaking(parentActivation, message.id, send)
|
||||
} else {
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
/** Send one report while translating only the parent's own rejection. */
|
||||
private sendReport(
|
||||
parent: Agent,
|
||||
@@ -745,23 +830,32 @@ export class SubagentContinuationManager {
|
||||
return lineage
|
||||
}
|
||||
|
||||
/** Reject new admission once the manager or this exact parent tree began draining. */
|
||||
private assertAdmitting(agent: Agent): void {
|
||||
if (this.draining) {
|
||||
throw new SubagentError(
|
||||
'continuable subagents are draining; the operation was not admitted',
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
/**
|
||||
* The teardown that closed continuable admission for this agent's lineage.
|
||||
* `'manager'` is the whole manager draining; an Agent is the exact scoped root
|
||||
* whose forest is closing.
|
||||
* @param agent - the agent whose lineage is tested.
|
||||
* @returns the closing teardown, or `undefined` while admission is open.
|
||||
*/
|
||||
private closingTeardownFor(agent: Agent): Agent | 'manager' | undefined {
|
||||
if (this.draining) return 'manager'
|
||||
const lineage = this.liveLineage(agent)
|
||||
for (const [root, members] of this.closingScopes) {
|
||||
if (members.has(agent) || lineage.includes(root)) {
|
||||
throw new SubagentError(
|
||||
`continuable subagents below parent "${root.id}" are draining; the operation was not admitted`,
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
if (members.has(agent) || lineage.includes(root)) return root
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reject new admission once the manager or this exact parent tree began draining. */
|
||||
private assertAdmitting(agent: Agent): void {
|
||||
const closing = this.closingTeardownFor(agent)
|
||||
if (closing === undefined) return
|
||||
throw new SubagentError(
|
||||
closing === 'manager'
|
||||
? 'continuable subagents are draining; the operation was not admitted'
|
||||
: `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`,
|
||||
'DRAINING',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -930,6 +1024,10 @@ export class SubagentContinuationManager {
|
||||
|
||||
const activation: Activation = {
|
||||
childId,
|
||||
// The durable lineage, not merely the caller: creation stamps this same
|
||||
// agent into the child's header, and cold resume authorized it against
|
||||
// the persisted header before materializing.
|
||||
parentSession: parent.id,
|
||||
provider,
|
||||
handle,
|
||||
ancestry: new WeakSet([handle.agent, ...parentLineage]),
|
||||
@@ -937,6 +1035,7 @@ export class SubagentContinuationManager {
|
||||
observer,
|
||||
disposal: undefined,
|
||||
accepted: new Set(),
|
||||
announced: false,
|
||||
poke: Promise.withResolvers<void>(),
|
||||
}
|
||||
// After transfer, any failure must dispose the created handle, remove the
|
||||
@@ -1038,9 +1137,13 @@ export class SubagentContinuationManager {
|
||||
// establish it before the message can enter the child's inbox.
|
||||
this.acquireOwnership(parent, activation.childId)
|
||||
const message = createUserMessage({ content, source })
|
||||
return this.admitWaking(activation, message.id, () => {
|
||||
const accepted = this.admitWaking(activation, message.id, () => {
|
||||
activation.handle.agent.followup(message)
|
||||
})
|
||||
// Past this point the caller has an id for this child, so its eventual
|
||||
// settlement is something the parent is owed an account of.
|
||||
activation.announced = true
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1261,6 +1364,12 @@ export class SubagentContinuationManager {
|
||||
// makes a racing delivery wait for release rather than cold-resume into the
|
||||
// still-registered agent.
|
||||
this.activations.delete(childId)
|
||||
// BEFORE releasing ownership, while the parent still counts this child and
|
||||
// therefore cannot be judged settled. Delivering after the release would
|
||||
// race a parent watcher that resumes one microtask later, finds itself
|
||||
// childless and quiet, and disposes an Agent whose `cancel()` clears the
|
||||
// inbox this notice is sitting in.
|
||||
this.notifySettlement(activation, activation.observer.terminal(failure))
|
||||
// Release ownership even on failure: a retained failed child would pin its
|
||||
// ancestors in `waiting` forever.
|
||||
this.releaseOwnership(childId)
|
||||
@@ -1270,6 +1379,75 @@ export class SubagentContinuationManager {
|
||||
if (failure !== undefined) throw failure
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the durable direct parent that this child produced everything it is
|
||||
* going to. Unconditional for every child the caller received an id for: it
|
||||
* does not consider whether the child reported, because the cases that most
|
||||
* need it — a token ceiling, a model failure, cancellation, teardown — are
|
||||
* exactly the ones where the child never got to choose. A materialization
|
||||
* rolled back before its first acceptance stays silent, since the caller was
|
||||
* told that child was not established. A parent that is no longer live is not
|
||||
* an error; the child's own Session remains the durable record either way.
|
||||
* A parent whose own lineage is already closing receives the notice without a
|
||||
* wake, because teardown is not a reason to start a turn.
|
||||
*
|
||||
* Never blocks disposal. A delivery failure is logged and dropped, because
|
||||
* retaining a child to retry a notice would pin its whole ancestry in
|
||||
* `waiting` forever.
|
||||
* @param activation - the settling Activation, still owned by its parent.
|
||||
* @param terminal - how this epoch ended, as the terminal edge will report it.
|
||||
*/
|
||||
private notifySettlement(activation: Activation, terminal: ActivationTerminal): void {
|
||||
if (!activation.announced) return
|
||||
try {
|
||||
const parent = this.ctx.agents.get(activation.parentSession)
|
||||
if (parent === undefined) return
|
||||
const summary = settlementSummary(activation.childId, terminal.stopReason)
|
||||
const message = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: summary },
|
||||
...terminal.output === undefined
|
||||
? [{ type: 'text' as const, text: 'It left no closing message.' }]
|
||||
: [{ type: 'text' as const, text: 'Its closing message:' }, ...terminal.output],
|
||||
],
|
||||
source: {
|
||||
kind: 'subagent-settled' as const,
|
||||
form: 'notice' as const,
|
||||
summary: boundContextSummary(summary),
|
||||
senderSessionId: activation.childId,
|
||||
},
|
||||
})
|
||||
// A parent whose own teardown already began must not be woken. Waking is
|
||||
// not a queue operation: `followup()` on a quiescent Agent starts a turn,
|
||||
// and `cancel()` does not arm against a later one, so a notice arriving
|
||||
// during teardown would spend a model request on an Agent its host is
|
||||
// about to dispose — once per tree layer, since each layer's own notice
|
||||
// then wakes the layer above it. Injecting delivers to a parent still
|
||||
// reading its inbox and records the account in the log either way; it
|
||||
// does NOT survive that parent's own disposal, whose `keepInbox: false`
|
||||
// cancel durably clears whatever it never claimed.
|
||||
if (this.closingTeardownFor(parent) !== undefined) {
|
||||
parent.inject(message)
|
||||
return
|
||||
}
|
||||
// An idle parent has nothing else to look at, so it gets one ordinary
|
||||
// turn. A busy parent is steered instead of woken: `Inbox.claim()` takes
|
||||
// the whole next-step batch at one boundary, so several children settling
|
||||
// together cost one step rather than one turn each. Steering rather than
|
||||
// injecting closes the window where a driver retires between this status
|
||||
// read and the send, which would strand the notice unclaimed.
|
||||
this.sendWaking(parent, message, () => {
|
||||
if (parent.status === 'idle') parent.followup(message)
|
||||
else parent.steer(message)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(
|
||||
`subagent "${activation.childId}" settlement notice was not delivered to its parent: `
|
||||
+ errorChain(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a best-effort final session flush after the child is quiescent.
|
||||
* Listener failure is logged because flush participation cannot identify a
|
||||
|
||||
@@ -119,6 +119,7 @@ export type {
|
||||
SubagentReportDelivery,
|
||||
SubagentReportMessageSource,
|
||||
SubagentReportOptions,
|
||||
SubagentSettledMessageSource,
|
||||
} from './continuation.ts'
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
|
||||
|
||||
@@ -18,12 +18,23 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session'
|
||||
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { finalAssistantOutput } from './assistant-output.ts'
|
||||
import { SubagentRunId } from './types.ts'
|
||||
import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
/**
|
||||
* How one Activation's residency epoch ended, as both the terminal lifecycle
|
||||
* edge and the manager's own parent delivery report it.
|
||||
*/
|
||||
export interface ActivationTerminal {
|
||||
/** Why this epoch's last ordinary turn ended, or `error` when teardown failed. */
|
||||
readonly stopReason: SubagentResult['stopReason']
|
||||
/** The epoch's final assistant content, absent when it produced none or failed. */
|
||||
readonly output?: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle observer for one Activation's residency epoch, so continuable
|
||||
* children emit the same start/end pair as one-shot runs. Package-private: the
|
||||
@@ -43,6 +54,15 @@ export interface ActivationObserver {
|
||||
* @param child - the quiescent child agent about to be released.
|
||||
*/
|
||||
capture(child: Agent): void
|
||||
/**
|
||||
* Resolve the terminal facts {@link settle} will publish, without publishing
|
||||
* them. The manager's parent delivery must run before the ownership release
|
||||
* that lets the parent settle, which is earlier than the terminal edge; both
|
||||
* therefore read one computation instead of restating the failure rule.
|
||||
* @param failure - the teardown or durability failure, or `undefined` on success.
|
||||
* @returns this epoch's stop reason and final assistant content.
|
||||
*/
|
||||
terminal(failure: unknown): ActivationTerminal
|
||||
/**
|
||||
* Publish the terminal edge exactly once, pairing this epoch's {@link start},
|
||||
* after the disposal outcome is known. Called only for a resident epoch: a
|
||||
@@ -165,9 +185,12 @@ export function createActivationObserver(
|
||||
let boundary = 0
|
||||
// Assigned by `capture()`, which the disposal path always runs before
|
||||
// `settle()`; a resident epoch therefore always has its facts by then.
|
||||
let captured: { stopReason: SubagentResult['stopReason']; output?: ContentBlock[] } = {
|
||||
stopReason: 'completed',
|
||||
}
|
||||
let captured: ActivationTerminal = { stopReason: 'completed' }
|
||||
// Teardown failure overrides the epoch's own outcome and withholds its
|
||||
// output: an answer this harness could not durably release is not a result.
|
||||
const terminal = (failure: unknown): ActivationTerminal => failure === undefined
|
||||
? captured
|
||||
: { stopReason: 'error' }
|
||||
return {
|
||||
start: (child: Agent): void => {
|
||||
boundary = child.session.events.length
|
||||
@@ -181,11 +204,12 @@ export function createActivationObserver(
|
||||
...output === undefined ? {} : { output },
|
||||
}
|
||||
},
|
||||
terminal,
|
||||
settle: (failure: unknown): void => {
|
||||
const output = failure === undefined ? captured.output : undefined
|
||||
const { stopReason, output } = terminal(failure)
|
||||
emit('subagent/end', {
|
||||
...identity,
|
||||
stopReason: failure === undefined ? captured.stopReason : 'error',
|
||||
stopReason,
|
||||
...output === undefined ? {} : { lastAssistantMessage: output },
|
||||
}, parent)
|
||||
},
|
||||
@@ -193,18 +217,24 @@ export function createActivationObserver(
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this child's last ordinary turn ended, for the terminal lifecycle edge.
|
||||
* The child's own `turn/end` is authoritative: teardown succeeding says nothing
|
||||
* about whether the model errored, hit its token ceiling, or was cancelled, so
|
||||
* deriving the reason from disposal would report failed work as completed.
|
||||
* Why this child's epoch ended, for the terminal lifecycle edge and the
|
||||
* manager's own parent delivery. The child's own log is authoritative:
|
||||
* teardown succeeding says nothing about whether the model errored, hit its
|
||||
* token ceiling, or was cancelled, so deriving the reason from disposal would
|
||||
* report failed work as completed.
|
||||
*
|
||||
* {@link foldConsumedWork} supplies both halves the raw turn sequence cannot:
|
||||
* which turn accounts for the work this epoch consumed, and whether accepted
|
||||
* work was cancelled after it without any turn opening over it. A recorded
|
||||
* failure still wins over a cancellation — stopping a child that had already
|
||||
* failed does not turn its failure into a cancellation.
|
||||
* @param events - this epoch's own event suffix.
|
||||
* @returns its terminal stop reason; `completed` when no ordinary turn closed.
|
||||
* @returns its terminal stop reason; `completed` only for an epoch that both
|
||||
* closed cleanly and had nothing left to run.
|
||||
*/
|
||||
function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopReason'] {
|
||||
const reason = findLastMessageTurnEnd(events)?.data.reason
|
||||
// No ordinary turn closed, so nothing failed either.
|
||||
if (reason === undefined) return 'completed'
|
||||
switch (reason.kind) {
|
||||
const { end, droppedUnrun } = foldConsumedWork(events)
|
||||
switch (end?.data.reason.kind) {
|
||||
case 'max-tokens':
|
||||
return 'max-tokens'
|
||||
case 'aborted':
|
||||
@@ -212,8 +242,15 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
|
||||
return 'aborted'
|
||||
case 'error':
|
||||
return 'error'
|
||||
// A pre-step rejection — a hook deny, a policy plugin — discarded input
|
||||
// this epoch had claimed: the work was declined, not done.
|
||||
case 'blocked':
|
||||
return 'refusal'
|
||||
// A clean ending and no accounting turn at all share one rule: the epoch
|
||||
// finished what it was given unless a cancelled queue says otherwise.
|
||||
case undefined:
|
||||
case 'completed':
|
||||
return 'completed'
|
||||
return droppedUnrun ? 'aborted' : 'completed'
|
||||
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
|
||||
* backend that adds a variant; treating an unnameable reason as success would
|
||||
* report failed work as completed. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-l
|
||||
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
@@ -142,6 +142,18 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
|
||||
}, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the top-level test parent out of a scripted model corpus. Every child
|
||||
* settlement wakes its parent, so a suite that scripts only child responses
|
||||
* would otherwise spend them on the parent's own turns.
|
||||
*/
|
||||
function parkParent(ctx: Context, parent: Agent): void {
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject !== parent) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
}
|
||||
|
||||
/** Observe calls at the Agent cancellation boundary without a production event. */
|
||||
function observeCancel(agent: Agent, callback: () => void): void {
|
||||
const cancel = agent.cancel.bind(agent)
|
||||
@@ -531,7 +543,10 @@ describe('SubagentService.followup residency routing', () => {
|
||||
await waitNoActivation(ctx, grandchild.childId)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(userTexts(loaded.events)).toEqual(['child task', 'while waiting'])
|
||||
// This child is itself a parent, so its grandchild's settlement notice is
|
||||
// an ordinary later user message in its log.
|
||||
expect(userTexts(loaded.events).slice(0, 2)).toEqual(['child task', 'while waiting'])
|
||||
expect(userTexts(loaded.events).slice(2).join('\n')).toContain('finished and will do no further work')
|
||||
})
|
||||
|
||||
it('rejects a parent that is not the durable direct parent', async () => {
|
||||
@@ -1182,6 +1197,7 @@ describe('continuable review regressions', () => {
|
||||
|
||||
it('reports this epoch\'s own output, captured while the child was still live', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
||||
parkParent(ctx, parent)
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
@@ -1254,9 +1270,10 @@ describe('continuable review regressions', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
// Reading the whole session would resurrect 'first answer' here.
|
||||
// Reading the whole session would resurrect 'first answer' here. The
|
||||
// rejection discarded the claimed follow-up, so the epoch reads as refused.
|
||||
expect(ends[0]!.lastAssistantMessage).toBeUndefined()
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(ends[0]!.stopReason).toBe('refusal')
|
||||
})
|
||||
|
||||
it('reports handle-disposal failure on the terminal edge', async () => {
|
||||
@@ -1439,11 +1456,13 @@ describe('continuable review regressions', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('reports completed when no ordinary turn closed', async () => {
|
||||
it('reports a prompt a pre-step rejection discarded as refusal', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
parkParent(ctx, parent)
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
// Block admission so the child's only turn never opens.
|
||||
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
|
||||
// the rejection discards it, and no step ever runs.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' }
|
||||
@@ -1452,8 +1471,10 @@ describe('continuable review regressions', () => {
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// The parent would otherwise believe a vetoed delivery was done and never
|
||||
// resend it — the one failure the settlement promise says cannot happen.
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(ends[0]!.stopReason).toBe('refusal')
|
||||
})
|
||||
|
||||
it('retains the Activation while an accepted message is still in the inbox', async () => {
|
||||
@@ -1482,15 +1503,538 @@ describe('continuable review regressions', () => {
|
||||
expect(ctx.agents.get(started.childId)).toBe(child)
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// Two child turns; the third request is the parent's own turn on the
|
||||
// settlement notice.
|
||||
expect(adapter.requests.filter(request => request.sessionId === started.childId)).toHaveLength(2)
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(loaded.events, 'queued')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/** Every settlement notice this agent received, in order, as flat text. */
|
||||
function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] {
|
||||
const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => {
|
||||
if (message.source.kind !== 'subagent-settled') return []
|
||||
return [{
|
||||
sender: message.source.senderSessionId,
|
||||
summary: message.source.summary,
|
||||
text: message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n'),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
describe('continuable settlement delivery', () => {
|
||||
it('tells the parent what the child finished with, without being asked', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
const notice = settlementNotices(parent)[0]!
|
||||
expect(notice.sender).toBe(started.childId)
|
||||
expect(notice.text).toBe(
|
||||
`Background subagent ${started.childId} finished and will do no further work unless you send it more.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
// The collapsed row states the outcome without the child's content.
|
||||
expect(notice.summary).toBe(
|
||||
`Background subagent ${started.childId} finished and will do no further work unless you send it more.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('delivers even when the child already reported for itself', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
await ctx.subagents.reportFrom(child, message('an explicit report'), {
|
||||
delivery: 'quiet',
|
||||
signal: testSignal,
|
||||
})
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
// The contract is unconditional precisely so the parent-side tool
|
||||
// description can promise it; bookkeeping "did it report?" would make the
|
||||
// promise conditional on a channel this manager does not own.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('delivers the terminal reason when the child never had a chance to report', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('half an ans'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} ran out of room before it finished.`
|
||||
+ '\nIts closing message:\nhalf an ans',
|
||||
)
|
||||
})
|
||||
|
||||
it('tells the parent a policy-rejected delivery was declined, not finished', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('parent ack')])
|
||||
// A pre-step rejection on the child — a UserPromptSubmit deny, a policy
|
||||
// plugin — discards the claimed prompt without running it.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject === parent) return next()
|
||||
return { kind: 'reject' }
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} declined the task.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a turn that failed before reaching its first step', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('the answer'), gate: releaseFirst.promise },
|
||||
{ chunks: textResponse('parent ack') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// The shipped durability checkpoint (`dsh-session-checkpoint-policy`) is
|
||||
// fail-closed at the step boundary, so a rejected write ends the turn after
|
||||
// it claimed its messages and before it entered a step.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => {
|
||||
if (subject.session.header.parentSession === undefined || turn < 2) return next()
|
||||
throw new Error('ENOSPC: no space left on device')
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await followup(ctx, parent, started.childId, message('second task'))
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
// The parent must not be told the child finished: the delivery it is still
|
||||
// waiting on was claimed out of the inbox and then swallowed by the failure.
|
||||
const child = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(hasUserText(child.events, 'second task')).toBe(false)
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} failed before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports accepted work cut short before its first step as stopped', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('the answer'), gate: releaseFirst.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// A step-boundary participant — the shipped durability checkpoint, a hook,
|
||||
// prompt assembly — holding the child's second turn open before its first
|
||||
// step, which is where teardown cancellation then catches it.
|
||||
ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => {
|
||||
if (subject.session.header.parentSession === undefined || turn < 2) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
// Queued while turn 1 still runs, so turn 2 opens and claims it without a
|
||||
// second model call: the Activation is mid-turn when the drain cancels it.
|
||||
await followup(ctx, parent, started.childId, message('second task'))
|
||||
releaseFirst.resolve(undefined)
|
||||
await atCheckpoint.promise
|
||||
const drained = drainManager(ctx)
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// Turn 2 leaves a balanced no-step `aborted` end, so the log alone would
|
||||
// answer with turn 1's clean completion and tell the parent its still-unrun
|
||||
// task had finished.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a child stopped before it ever reached the model as stopped', async () => {
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const { ctx, parent } = await setupWith(new GatedAdapter([]))
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.session.header.parentSession === undefined) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await atCheckpoint.promise
|
||||
const drained = drainManager(ctx)
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// This epoch closed no stepped turn at all, which on its own reads as "had
|
||||
// nothing to report"; only the interruption distinguishes it from a child
|
||||
// that genuinely finished with no output.
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a child an ancestor interrupted before its first step as stopped', async () => {
|
||||
const atCheckpoint = Promise.withResolvers<undefined>()
|
||||
const releaseCheckpoint = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('parent ack') }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.session.header.parentSession === undefined) return next()
|
||||
atCheckpoint.resolve(undefined)
|
||||
await releaseCheckpoint.promise
|
||||
return next()
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await atCheckpoint.promise
|
||||
// The shipped interrupt path: nothing about it runs inside this manager, so
|
||||
// no pre-cancel sample could see it — the child's own log has to say so.
|
||||
ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent })
|
||||
releaseCheckpoint.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('reports accepted work cancelled before any turn could open as stopped', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const releaseGrandchild = Promise.withResolvers<undefined>()
|
||||
const releaseMaintenance = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('the answer'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const child = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
// A descendant keeps the child resident once its own turn closes, so the
|
||||
// maintenance phase below is reachable without racing settlement.
|
||||
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
|
||||
releaseChild.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(child.status).toBe('idle') })
|
||||
|
||||
// Context maintenance folds into `idle` and defers waking work, so this
|
||||
// delivery is accepted with no turn to claim it.
|
||||
const maintaining = child.runMaintenance(async () => { await releaseMaintenance.promise })
|
||||
await followup(ctx, parent, started.childId, message('never runs'))
|
||||
const drained = drainManager(ctx)
|
||||
releaseMaintenance.resolve(undefined)
|
||||
releaseGrandchild.resolve(undefined)
|
||||
await maintaining
|
||||
await drained
|
||||
|
||||
// Turn 1 closed cleanly and no later turn opened, so the cancelled queue is
|
||||
// the only record that this epoch was cut short.
|
||||
expect(hasUserText(child.session.events, 'never runs')).toBe(false)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIts closing message:\nthe answer',
|
||||
)
|
||||
})
|
||||
|
||||
it('withholds an outcome the harness could not durably release', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { handle: { dispose(): Promise<void> } }> }
|
||||
}).continuations
|
||||
const activation = await vi.waitFor(() => {
|
||||
const live = manager.activations.get(started.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const dispose = activation.handle.dispose.bind(activation.handle)
|
||||
activation.handle.dispose = async () => {
|
||||
await dispose()
|
||||
throw new Error('scope unwind failed')
|
||||
}
|
||||
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} failed before it finished.\nIt left no closing message.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('gives an idle parent one ordinary turn on the notice', async () => {
|
||||
const { ctx, parent, adapter } = await setup([textResponse('the answer'), textResponse('parent ack')])
|
||||
const turnStarts: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn)
|
||||
})
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1)
|
||||
})
|
||||
expect(turnStarts).toEqual([1])
|
||||
})
|
||||
|
||||
it('batches simultaneous notices into one step of a busy parent', async () => {
|
||||
const releaseChildren = Promise.withResolvers<undefined>()
|
||||
const releaseParent = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('parent works'), gate: releaseParent.promise },
|
||||
{ chunks: textResponse('first child'), gate: releaseChildren.promise },
|
||||
{ chunks: textResponse('second child'), gate: releaseChildren.promise },
|
||||
{ chunks: textResponse('parent reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
// Open a parent turn first, so both notices arrive while it is running.
|
||||
parent.followup(createUserMessage({ content: message('start working'), source: { kind: 'user' } }))
|
||||
await vi.waitFor(() => { expect(parent.status).toBe('running') })
|
||||
|
||||
const first = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const second = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
releaseChildren.resolve(undefined)
|
||||
await waitNoActivation(ctx, first.childId)
|
||||
await waitNoActivation(ctx, second.childId)
|
||||
|
||||
// Both notices are waiting for the same step boundary, not two turns.
|
||||
expect(parent.inbox.nextStep).toHaveLength(2)
|
||||
expect(parent.inbox.nextTurn).toHaveLength(0)
|
||||
const turnStarts: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session.id === parent.id && event.type === 'turn/start') turnStarts.push(event.data.turn)
|
||||
})
|
||||
releaseParent.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(2) })
|
||||
expect(turnStarts).toEqual([])
|
||||
// Both children released together, so which settles first is not ordered.
|
||||
expect(new Set(settlementNotices(parent).map(entry => entry.sender)))
|
||||
.toEqual(new Set([first.childId, second.childId]))
|
||||
})
|
||||
|
||||
it('holds a maintaining parent live until it can read the notice', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const releaseSecond = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('outer') },
|
||||
{ chunks: textResponse('first inner'), gate: releaseFirst.promise },
|
||||
{ chunks: textResponse('second inner'), gate: releaseSecond.promise },
|
||||
{ chunks: textResponse('outer reacts') },
|
||||
{ chunks: textResponse('root reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const outer = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const middle = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(outer.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const first = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
const second = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
||||
|
||||
// `Agent.status` folds maintenance into `idle`, and a waking send behind it
|
||||
// only arms a deferred wake. The first child's release moves the middle
|
||||
// Activation's settlement watcher onto its quiescence race; the second one
|
||||
// then arrives at exactly the point where an unaccounted delivery would be
|
||||
// judged quiet, settled, and cancelled — clearing the inbox it sits in.
|
||||
const maintaining = Promise.withResolvers<undefined>()
|
||||
const maintenance = middle.runMaintenance(async () => { await maintaining.promise })
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, first.childId)
|
||||
releaseSecond.resolve(undefined)
|
||||
await waitNoActivation(ctx, second.childId)
|
||||
expect(ctx.agents.get(outer.childId)).toBe(middle)
|
||||
|
||||
maintaining.resolve(undefined)
|
||||
await maintenance
|
||||
await vi.waitFor(() => { expect(settlementNotices(middle)).toHaveLength(2) })
|
||||
expect(settlementNotices(middle).map(entry => entry.sender))
|
||||
.toEqual([first.childId, second.childId])
|
||||
await waitNoActivation(ctx, outer.childId)
|
||||
})
|
||||
|
||||
it('delivers before releasing the ownership that lets the parent settle', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('outer') },
|
||||
{ chunks: textResponse('inner'), gate: releaseChild.promise },
|
||||
{ chunks: textResponse('outer reacts') },
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const outer = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const middle = await vi.waitFor(() => {
|
||||
const live = ctx.agents.get(outer.childId)
|
||||
expect(live).toBeDefined()
|
||||
return live!
|
||||
})
|
||||
const inner = await ctx.subagents.startContinuable(startSpec(middle))
|
||||
await vi.waitFor(() => { expect(middle.status).toBe('idle') })
|
||||
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: { activations: Map<SessionId, { ownedChildren: Set<SessionId> }> }
|
||||
}).continuations
|
||||
let ownedAtDelivery: SessionId[] | undefined
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (agent !== middle || message.source.kind !== 'subagent-settled') return
|
||||
ownedAtDelivery = [...manager.activations.get(middle.id)!.ownedChildren]
|
||||
})
|
||||
|
||||
releaseChild.resolve(undefined)
|
||||
await waitNoActivation(ctx, inner.childId)
|
||||
// Still owned at delivery: the parent is structurally unable to settle in
|
||||
// the window the notice crosses, rather than winning a race against it.
|
||||
expect(ownedAtDelivery).toEqual([inner.childId])
|
||||
await waitNoActivation(ctx, outer.childId)
|
||||
})
|
||||
|
||||
it('does not wake a parent whose own teardown already began', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = drainManager(ctx)
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
|
||||
// Delivered and durably logged, but no turn: waking a parent the host is
|
||||
// about to dispose spends a model request nothing reads. What happens to the
|
||||
// message when that parent is disposed next is pinned by the test below.
|
||||
expect(settlementNotices(parent)).toHaveLength(1)
|
||||
expect(settlementNotices(parent)[0]!.text).toBe(
|
||||
`Background subagent ${started.childId} was stopped before it finished.`
|
||||
+ '\nIt left no closing message.',
|
||||
)
|
||||
expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
|
||||
expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(parent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('does not wake a parent below a scoped teardown root', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = ctx.subagents.drainContinuableDescendants([parent])
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
|
||||
expect(settlementNotices(parent)).toHaveLength(1)
|
||||
expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('records but cannot deliver a teardown notice once the parent is disposed too', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('interrupted'), gate: hold.promise }])
|
||||
const { ctx } = await setupWith(adapter)
|
||||
const parentId = SessionId('closing-parent')
|
||||
const host = await ctx.agents.create({
|
||||
sessionId: parentId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const started = await ctx.subagents.startContinuable(startSpec(host.agent))
|
||||
await vi.waitFor(() => { expect(ctx.agents.get(started.childId)).toBeDefined() })
|
||||
|
||||
const drained = ctx.subagents.drainContinuableDescendants([host.agent])
|
||||
hold.resolve(undefined)
|
||||
await drained
|
||||
expect(settlementNotices(host.agent)).toHaveLength(1)
|
||||
|
||||
// Disposal is a `keepInbox: false` cancel, so it durably cancels the notice
|
||||
// it never claimed. Teardown delivery therefore reaches a parent that is
|
||||
// still resident — a resumed one reads the log, not a pending message — and
|
||||
// no wording anywhere may promise otherwise.
|
||||
await host.dispose()
|
||||
const resumed = await ctx.agents.resume({
|
||||
resumeSessionId: parentId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(settlementNotices(resumed.agent)).toEqual([])
|
||||
await resumed.dispose()
|
||||
// The account is still in the durable log: delivered, then cancelled unread.
|
||||
const persisted = await ctx.sessionPersistence.load(parentId)
|
||||
expect(persisted.events.flatMap(event => event.type === 'agent/inbox/spliced'
|
||||
? [{ inserted: event.data.inserted.length, removed: event.data.removedCount ?? 0 }]
|
||||
: [])).toEqual([{ inserted: 1, removed: 0 }, { inserted: 0, removed: 1 }])
|
||||
})
|
||||
|
||||
it('drops the notice without disturbing teardown when the parent is gone', async () => {
|
||||
const releaseChild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: releaseChild.promise }])
|
||||
const { ctx } = await setupWith(adapter)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = (text: string) => { warnings.push(text) }
|
||||
const host = await ctx.agents.create({
|
||||
sessionId: SessionId('disposable-parent'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const started = await ctx.subagents.startContinuable(startSpec(host.agent))
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
releaseChild.resolve(undefined)
|
||||
await host.dispose()
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('logs a rejected notice instead of failing the child\'s teardown', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('the answer')])
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = (text: string) => { warnings.push(text) }
|
||||
vi.spyOn(parent, 'followup').mockImplementation(() => {
|
||||
throw new Error('parent closed during delivery')
|
||||
})
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', (info) => { ends.push(info) })
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(ends[0]!.stopReason).toBe('completed')
|
||||
expect(warnings.some(warning => warning.includes('settlement notice was not delivered'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent about a child the caller was told does not exist', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const drains: Promise<void>[] = []
|
||||
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
await Promise.all(drains)
|
||||
expect(settlementNotices(parent)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('continuable lifecycle observation', () => {
|
||||
it('emits one paired start/end per residency epoch', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
parkParent(ctx, parent)
|
||||
const starts: SubagentRunInfo[] = []
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/start', (info) => { starts.push(info) })
|
||||
@@ -1510,6 +2054,8 @@ describe('continuable lifecycle observation', () => {
|
||||
expect(starts.map(info => info.provider)).toEqual(['spawn', 'spawn'])
|
||||
// Each end pairs its own start's runId.
|
||||
expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId))
|
||||
// Both epochs ran their own scripted response; neither exhausted the corpus.
|
||||
expect(ends.map(info => info.stopReason)).toEqual(['completed', 'completed'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/tool-subagent-control/README.md
|
||||
README.md: 460617378841b791f7d8750962a9e4549643928e
|
||||
README.zh.md: 5939a1636a5f92dc797e5788c88bba7048ec7c96
|
||||
README.md: 91f8d23ac092049e5315418070cdaae025054860
|
||||
README.zh.md: 981e3f4efa2a904918d3f4174a2188fc2965c626
|
||||
|
||||
@@ -8,7 +8,7 @@ The tool performs no lifecycle routing — residency and cold resume belong to t
|
||||
|
||||
`interrupt_agent(agent_id)` passes `exec.agent` as the exact live ancestor authority for `ctx.subagents.interrupt()`: the target may be a direct child or a deeper descendant, and the service — never this tool — verifies the caller against the target Activation's recorded lineage. Only the target's current turn stops (`keepInbox`): queued messages stay parked until a later `send_message`, published descendants keep running, and the child stays available for follow-ups. The call returns as soon as the stop request is accepted, without waiting for target quiescence; an absent or already-settled target is an accepted no-op, while self, sibling, stale, and non-ancestor callers become errored results.
|
||||
|
||||
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), `complete` (storage only). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
|
||||
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), or `ready` (storage only and resumable rather than terminal). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -58,7 +58,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `complete` = storage only; a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `ready` = storage only; resumable rather than terminal, not a result waiting to be collected — a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -72,5 +72,5 @@ Append-only; each result follows the reusable request prefix.
|
||||
|
||||
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work lands in the durable child Session and is never collected through this tool. A child granted `report` may send selected content back separately, but that message is not this call's result.
|
||||
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
|
||||
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `ready`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
|
||||
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`interrupt_agent(agent_id)` 将 `exec.agent` 作为 `ctx.subagents.interrupt()` 的确切在线 ancestor 授权传入:目标可以是直接 child 或更深的后代,由服务——而不是本工具——依据目标 Activation 记录的 lineage 校验调用方。只有目标的当前轮次会停止(`keepInbox`):已排队的消息保持暂停直到之后的 `send_message`,已发布的后代继续运行,child 也仍可接受后续消息。调用在停止请求被接受后立即返回,不等待目标完全停稳;目标不存在或已结算是被接受的 no-op,而 self、sibling、过期与非 ancestor 调用方会成为出错结果。
|
||||
|
||||
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id,并且不使用 cursor,将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()`;`descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child,并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id,可能指向输出中省略的普通会话。对于调用本工具的 agent,只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`(driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent)、`complete`(仅存于存储)。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
|
||||
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id,并且不使用 cursor,将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()`;`descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child,并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id,可能指向输出中省略的普通会话。对于调用本工具的 agent,只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`(driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent)或 `ready`(仅存于存储,表示可恢复而非终态)。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`complete` 表示仅存于存储;处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`ready` 表示仅存于存储;可恢复而非终态,也不表示有结果等待收集——处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -72,5 +72,5 @@
|
||||
|
||||
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
|
||||
- **不对当前轮次进行 steering(中途引导)**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `complete` 的 child;跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
|
||||
- **列表是快照,而非投递承诺**:它可能与发布、dispose(资源释放)或后续消息发生竞态,另一个进程也可能激活当前进程报告为 `ready` 的 child;跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
|
||||
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。
|
||||
|
||||
@@ -32,7 +32,7 @@ type ListAgentsEntry =
|
||||
readonly kind: 'child'
|
||||
readonly id: SessionId
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'idle' | 'complete'
|
||||
readonly status: 'running' | 'idle' | 'ready'
|
||||
readonly parent?: SessionId
|
||||
readonly depth?: number
|
||||
}
|
||||
@@ -52,11 +52,13 @@ function resolveListAgentsRequest(request: ListAgentsRequest): ListAgentsSpec {
|
||||
/**
|
||||
* Refine one candidate's status through the live Agent registry: `running`
|
||||
* for an active driver, `idle` for a resident Agent between turns (possibly
|
||||
* waiting on agents it started), and `complete` when no live Agent remains.
|
||||
* waiting on agents it started), and `ready` when no live Agent remains.
|
||||
* `ready` preserves resumability without presenting an inactive conversation
|
||||
* as a terminal result to collect.
|
||||
*/
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
|
||||
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'ready' {
|
||||
const agent = agents.get(id)
|
||||
if (agent === undefined) return 'complete'
|
||||
if (agent === undefined) return 'ready'
|
||||
return agent.status === 'running' ? 'running' : 'idle'
|
||||
}
|
||||
|
||||
@@ -90,10 +92,12 @@ export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'List your continuable background subagents by durable id and label. Status comes from the live '
|
||||
'List your continuable background subagents by durable id and label. Use it to recall which ones '
|
||||
+ 'you started, not to poll for completion — you are told when one finishes. Status comes from the live '
|
||||
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
|
||||
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
|
||||
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ '(it may be waiting on agents it started), and ready means it exists only in storage — resumable, not '
|
||||
+ 'terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same '
|
||||
+ 'conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
|
||||
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
|
||||
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
|
||||
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
|
||||
@@ -118,7 +122,7 @@ export function apply(ctx: Context): void {
|
||||
kind: { type: 'string', required: true, enum: ['child'] },
|
||||
id: { type: 'string', required: true },
|
||||
label: { type: 'string', required: true },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
|
||||
status: { type: 'string', required: true, enum: ['running', 'idle', 'ready'] },
|
||||
parent: { type: 'string' },
|
||||
depth: { type: 'number' },
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
import { parkParent } from './park-parent.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
@@ -63,6 +64,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
parkParent(ctx, parent)
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
@@ -177,8 +179,10 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
// `ready` is the resumable counterpart to a live `running` record, not a
|
||||
// claim that the child's conversation ended with a result to collect.
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\n`
|
||||
`${started.childId} [ready] — real child\n`
|
||||
+ 'running-child [running] — still working\n'
|
||||
+ 'waiting-child [idle] — waiting on descendants\n'
|
||||
+ 'broken-child [diagnostic: corrupt]',
|
||||
@@ -215,7 +219,21 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
|
||||
expect(text(result)).toBe(`${started.childId} [ready] — summarize the doc`)
|
||||
})
|
||||
|
||||
it('describes ready as resumable and pins the status vocabulary', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const schema = ctx.tools.schemas().find(candidate => candidate.name === 'list_agents')
|
||||
// Completion reaches the parent through its notice; listing is discovery,
|
||||
// so its inactive status must not send the model looking for a result.
|
||||
expect(schema?.description).toContain('you are told when one finishes')
|
||||
expect(schema?.description).toContain('resumable, not terminal')
|
||||
// The enum is the closed vocabulary the model renders, so pin it rather than
|
||||
// scanning prose that legitimately reads "not to poll for completion".
|
||||
const variants = ctx.tools.get('list_agents')?.output.schema.items?.oneOf ?? []
|
||||
const child = variants.find(variant => variant.properties?.kind?.enum?.includes('child'))
|
||||
expect(child?.properties?.status?.enum).toEqual(['running', 'idle', 'ready'])
|
||||
})
|
||||
|
||||
it('fails loud when invoked without a calling agent', async () => {
|
||||
@@ -321,7 +339,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
'deep-leaf [complete] parent=one-shot-mid depth=2 — deep leaf\n'
|
||||
'deep-leaf [ready] parent=one-shot-mid depth=2 — deep leaf\n'
|
||||
+ `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
|
||||
)
|
||||
})
|
||||
|
||||
22
packages/subagent/tool-subagent-control/tests/park-parent.ts
Normal file
22
packages/subagent/tool-subagent-control/tests/park-parent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared suite helper: keep this package's stand-in parent out of a scripted
|
||||
* model corpus.
|
||||
* @module park-parent
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Reject every step of the stand-in parent. Each child settlement wakes its
|
||||
* parent, and these suites size their scripts for child turns only; the tests
|
||||
* assert on delivery rather than on the parent's own turn.
|
||||
* @param ctx - the booted test context.
|
||||
* @param parent - the stand-in parent whose turns must not reach the model.
|
||||
*/
|
||||
export function parkParent(ctx: Context, parent: { id: SessionId }): void {
|
||||
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
|
||||
if (subject.id !== parent.id) return next()
|
||||
return { kind: 'reject' as const }
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { parkParent } from './park-parent.ts'
|
||||
|
||||
/** One scripted response that may wait on a caller-released gate before streaming. */
|
||||
interface GatedEntry {
|
||||
@@ -62,6 +63,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
parkParent(ctx, parent)
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
|
||||
@@ -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/tool-subagent-report/README.md
|
||||
README.md: 1772b72d83563e89ed0e3814e515b06a2b36fe0f
|
||||
README.zh.md: 94101fbc9611a04c56ed3a9171889499c9144c21
|
||||
README.md: b38b6b541d9c03174de1515df7a0babe5da055b4
|
||||
README.zh.md: 0c5c063eded1e9057ee8886f03dd6494dd9eeed8
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
|
||||
The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it, and installs the prompt section that instructs the child to use it. The package registers a continuable-child setup contribution instead of a global tool, so the tool and its guidance exist only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package.
|
||||
|
||||
A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
The child-scoped `tool:report` prompt section instructs the child to call `report` once before finishing, with a self-contained answer, and earlier whenever a partial finding changes what the parent should do next. The instruction is guidance, not enforcement: the mechanism still accepts zero or many calls in one turn, and no runtime path rejects a child that never reports. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted).
|
||||
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
|
||||
`reportDelivery` selects parent scheduling for every accepted report. `wakeup` (the default) uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. It is the default because a parent that already parked has no other reason to look, so quiet delivery would leave an accepted report unread until something unrelated woke it. `quiet` uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call.
|
||||
|
||||
Scope-local registration deliberately survives the child's global `toolFilter`, so a delegation allow-list cannot remove the only return channel. A deployment that requires a child with no return channel omits this package.
|
||||
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` into a minted child scope. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private.
|
||||
The contribution body is exported as `installReportTool(childCtx, ctx, delivery)` so inspection consumers can install `report` and its guidance into a minted child scope, and returns the one disposer revoking both. The generated tool catalog uses that path because the global registry cannot expose a scope-local schema. Production composition still enters through `apply()`; the subagent seam's contribution registry remains private.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -18,15 +18,15 @@ The contribution body is exported as `installReportTool(childCtx, ctx, delivery)
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that reporting is explicit and repeatable, reaches only the Agent that started the child, and does not end the turn. It carries no recipient or delivery-mode parameter.
|
||||
The generated [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report): one required `output` string. Its description states that the child must report once before finishing, that reporting reaches only the Agent that started the child, and that it does not end the turn. It carries no recipient or delivery-mode parameter. The separate `tool:report` prompt section repeats the obligation outside the schema, where a child that ignores tool descriptions still reads it.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost per continuable-child request, and none in any other Agent's requests.
|
||||
Fixed schema and prompt-section cost per continuable-child request, and none in any other Agent's requests.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable within a child; the schema does not change at runtime. Removing the package revokes the schema from resident children, which changes their next request prefix.
|
||||
Prefix-stable within a child; neither the schema nor the section changes at runtime. Removing the package revokes both from resident children, which changes their next request prefix.
|
||||
|
||||
### Report result
|
||||
|
||||
@@ -36,7 +36,7 @@ Prefix-stable within a child; the schema does not change at runtime. Removing th
|
||||
|
||||
#### Token effect
|
||||
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: quiet delivery adds it to the parent's next request, while waking delivery makes it the sole ordinary message of one new parent turn.
|
||||
One short acknowledgement per call in the reporting child. The reported content is additionally billed to the parent: waking delivery makes it the sole ordinary message of one new parent turn, while quiet delivery adds it to the parent's next request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -61,6 +61,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
|
||||
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
|
||||
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
|
||||
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` only on that child's next Activation, while removing the package revokes the schema from resident children immediately.
|
||||
- **Granting waits for the next Activation; revocation is immediate** — installing this package after a child becomes resident grants `report` and its guidance only on that child's next Activation, while removing the package revokes both from resident children immediately.
|
||||
- **Nested reporting reaches exactly one edge upward** — a grandchild reports to its direct child parent, never to the top-level coordinator, which must explicitly report a derived update later.
|
||||
- **No rate limiting** — `wakeup` mode can amplify model work when nested children report frequently; the deployment owns that choice by selecting the mode.
|
||||
- **No rate limiting** — the default `wakeup` mode can amplify model work when nested children report frequently; a deployment that accepts unread reports over that amplification selects `quiet`.
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体),并安装指示子级使用该通道的提示词 section。本包注册的是可继续子级设置贡献,而不是全局工具,因此该工具及其指引只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域能力;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。
|
||||
|
||||
子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
子级作用域的 `tool:report` 提示词 section 要求子级在结束前调用一次 `report` 并给出自足的答案,并在部分发现会改变父级下一步动作时提前上报。该指令是引导而非强制:机制本身仍接受一个轮次中调用零次或多次,也没有任何运行时路径会拒绝从不上报的子级。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方确切在线的 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始由宿主管理的 dispose(资源释放)但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复依据,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。
|
||||
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
`reportDelivery` 为每条已接受的报告选择父级调度方式。`wakeup`(默认值)使用 `parent.followup()`,恰好创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。之所以作为默认值:已经停驻的父级没有别的理由再去查看,静默投递会让一条已被接受的报告一直无人阅读,直到别的事件把父级唤醒。`quiet` 使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。
|
||||
|
||||
作用域局部注册有意不受子级全局 `toolFilter` 影响,因此委派允许列表无法移除唯一的返回通道。需要子级不具备返回通道的部署应省略本包。
|
||||
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 安装到新创建的子级作用域中。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
贡献体以 `installReportTool(childCtx, ctx, delivery)` 导出,以便检查类消费方把 `report` 及其指引安装到新创建的子级作用域中,并返回同时撤销两者的唯一 disposer。全局注册表无法公开作用域局部 schema,因此生成的工具目录会使用这条路径。生产组合仍通过 `apply()` 进入;subagent seam 的贡献注册表保持私有。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明上报需要显式调用且可以重复,只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。
|
||||
已生成的 [`report` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-report):包含一个必填 `output` 字符串。其描述说明子级必须在结束前上报一次,上报只会到达启动该子级的 Agent,并且不会结束轮次。它不包含接收方或投递模式参数。独立的 `tool:report` 提示词 section 在 schema 之外重申该义务,使忽略工具描述的子级仍能读到。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个可继续子级请求支付固定的 schema 成本,其他任何 Agent 的请求均无此成本。
|
||||
每个可继续子级请求支付固定的 schema 与提示词 section 成本,其他任何 Agent 的请求均无此成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
子级中的前缀保持稳定;schema 不会在运行时改变。移除本包会从驻留子级中撤销该 schema,从而改变其下一次请求前缀。
|
||||
子级中的前缀保持稳定;schema 与该 section 都不会在运行时改变。移除本包会从驻留子级中撤销两者,从而改变其下一次请求前缀。
|
||||
|
||||
### 上报结果
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:静默投递会把内容加入父级的下一次请求,唤醒投递则会使该内容成为一个新父级轮次中唯一的普通消息。
|
||||
每次调用都会在执行上报的子级中产生一条简短确认消息。父级还会为上报内容支付 token 成本:唤醒投递会使该内容成为一个新父级轮次中唯一的普通消息,静默投递则把内容加入父级的下一次请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -61,6 +61,6 @@
|
||||
- **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由继续执行管理器拥有的父级,管理器的准入边界会在整片森林拆卸期间拒绝该上报。
|
||||
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。
|
||||
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report`;移除本包则会立即从驻留子级撤销该 schema。
|
||||
- **授权须等到下一个 Activation,撤销则立即生效**:子级驻留后再安装本包,只会在该子级的下一个 Activation 中授予 `report` 及其指引;移除本包则会立即从驻留子级撤销两者。
|
||||
- **嵌套上报只向上到达一条直接边**:孙级只向作为其直接父级的子级上报,不会直接到达顶层协调器;该直接父级必须随后显式发出一条衍生更新。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,`wakeup` 模式会放大模型工作量;部署通过选择模式自行承担这一取舍。
|
||||
- **没有速率限制**:嵌套子级频繁上报时,默认的 `wakeup` 模式会放大模型工作量;宁可接受报告无人阅读也要避免这种放大的部署应选择 `quiet`。
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The child-scoped `report` tool, installed into every continuable in-process
|
||||
* child's unpublished context. Roots, one-shot children, remote providers, and
|
||||
* agentless executions never see the registration.
|
||||
* The child-scoped `report` tool and its usage guidance, installed into every
|
||||
* continuable in-process child's unpublished context. Roots, one-shot children,
|
||||
* remote providers, and agentless executions never see the registration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-subagent-report
|
||||
*/
|
||||
@@ -11,86 +11,131 @@ import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-subagent-report'
|
||||
// The contribution registers only through childCtx.tools, but declaring tools
|
||||
// makes Loader ordering fail at load instead of the next child materialization.
|
||||
export const inject = ['subagents', 'tools']
|
||||
// The contribution registers only through childCtx.tools and
|
||||
// childCtx.systemPrompt, but declaring both services makes Loader ordering fail
|
||||
// at load instead of at the next child materialization.
|
||||
export const inject = ['subagents', 'tools', 'systemPrompt']
|
||||
|
||||
/** Guidance order after every per-tool section a continuable child can carry. */
|
||||
const REPORT_SECTION_ORDER = 117
|
||||
|
||||
/** Config: how accepted reports are scheduled on the parent. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Parent scheduling (default `quiet`). `quiet` adds context without waking;
|
||||
* `wakeup` creates one ordinary later parent turn.
|
||||
* Parent scheduling (default `wakeup`). `wakeup` creates one ordinary later
|
||||
* parent turn; `quiet` adds context without waking, so a parked parent learns
|
||||
* of the report only when something else wakes it.
|
||||
*/
|
||||
reportDelivery?: SubagentReportDelivery
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('quiet'),
|
||||
reportDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Install `report` into one continuable child's scope.
|
||||
* @param childCtx - child-scoped context receiving the tool.
|
||||
* Install `report` and its usage guidance into one continuable child's scope.
|
||||
* Both registrations are owned by that scope and are therefore invisible to the
|
||||
* child's parent and siblings.
|
||||
* @param childCtx - child-scoped context receiving the tool and the guidance.
|
||||
* @param ctx - service context used for delivery.
|
||||
* @param delivery - resolved deployment scheduling policy.
|
||||
* @returns disposer for this one registration.
|
||||
* @returns disposer that attempts both child registrations before reporting cleanup failures.
|
||||
*/
|
||||
export function installReportTool(
|
||||
childCtx: Context,
|
||||
ctx: Context,
|
||||
delivery: SubagentReportDelivery,
|
||||
): () => void {
|
||||
return childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this zero or more times for progress, '
|
||||
+ 'findings, or a final answer. Reporting does not end your turn or finish your work, and only your '
|
||||
+ 'direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.',
|
||||
parameters: {
|
||||
output: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Self-contained content for your parent; it does not see your private work.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
messageId: { type: 'string', required: true },
|
||||
const disposeSection = childCtx.systemPrompt.section({
|
||||
name: 'tool:report',
|
||||
order: REPORT_SECTION_ORDER,
|
||||
text: 'Deliver your result with the report tool before you finish: call it once with a self-contained '
|
||||
+ 'answer. The agent that started you shares your workspace but does not automatically receive your '
|
||||
+ 'transcript, tool output, or reasoning, so a closing remark such as "done" leaves it nothing it can '
|
||||
+ 'use. Report earlier as well whenever a partial finding changes what that agent should do next; '
|
||||
+ 'reporting never ends your turn.',
|
||||
})
|
||||
let disposeTool: () => void
|
||||
try {
|
||||
disposeTool = childCtx.tools.register(defineTool({
|
||||
name: 'report',
|
||||
description:
|
||||
'Report selected content to the agent that started you. Call this once before you finish, with a '
|
||||
+ 'self-contained final result, and earlier for progress or findings that change what that agent does '
|
||||
+ 'next. That agent shares your workspace but does not automatically receive your transcript, tool '
|
||||
+ 'output, or reasoning, so finishing your work is not itself a result. Reporting does not end your '
|
||||
+ 'turn or finish your work, and only your direct parent receives it. A failed call may still have '
|
||||
+ 'arrived, so do not blindly repeat it.',
|
||||
parameters: {
|
||||
output: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Actionable content for your parent; summarize conclusions and reference relevant shared paths.',
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `report accepted by the agent that started you as message ${value.messageId}`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: args.output }]
|
||||
// Scope-local resolution guarantees an Agent. The service still verifies
|
||||
// its exact live Activation identity at the authority boundary.
|
||||
const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, {
|
||||
delivery,
|
||||
signal: exec.signal,
|
||||
})
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
messageId: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `report accepted by the agent that started you as message ${value.messageId}`,
|
||||
}],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const content: ContentBlock[] = [{ type: 'text', text: args.output }]
|
||||
// Scope-local resolution guarantees an Agent. The service still verifies
|
||||
// its exact live Activation identity at the authority boundary.
|
||||
const messageId = await ctx.subagents.reportFrom(exec.agent as Agent, content, {
|
||||
delivery,
|
||||
signal: exec.signal,
|
||||
})
|
||||
return { messageId }
|
||||
},
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
disposeSection()
|
||||
} catch (rollbackError: unknown) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
'failed to register the report tool and roll back its prompt guidance',
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [disposeTool, disposeSection]) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'failed to revoke report tool and prompt registrations')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the continuable-child contribution.
|
||||
* @param ctx - context carrying tools and the subagent service.
|
||||
* @param ctx - context carrying tools, the system prompt, and the subagent service.
|
||||
* @param config - deployment scheduling policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
// Config() applies the schema default ('quiet') at runtime; the schemastery
|
||||
// return type keeps the input's optional shape, so assert the resolved
|
||||
// shape here — no runtime fallback exists or is wanted.
|
||||
// Config() applies the schema default at runtime; the schemastery return
|
||||
// type keeps the input's optional shape, so assert the resolved one.
|
||||
const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery }
|
||||
ctx.subagents.registerContinuableSetup(childCtx =>
|
||||
installReportTool(childCtx, ctx, reportDelivery))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
@@ -96,6 +97,17 @@ function callReport(ctx: Context, child: Agent, output: string, signal = testSig
|
||||
})
|
||||
}
|
||||
|
||||
/** Occupy the child-local report name to force installation rollback. */
|
||||
function registerReportConflict(child: Agent): () => void {
|
||||
return child.ctx.tools.register({
|
||||
name: 'report',
|
||||
description: 'conflicting report fixture',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: { schema: { type: 'object', properties: {} }, render: () => [] },
|
||||
execute: () => Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Reports already visible or still pending in one Agent. */
|
||||
function reports(agent: Agent): { id: string; text: string; sender: string }[] {
|
||||
const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
@@ -113,6 +125,12 @@ function renderedText(result: { content: { type: string; text?: string }[] }): s
|
||||
return result.content.flatMap(block => block.type === 'text' ? [block.text ?? ''] : []).join('')
|
||||
}
|
||||
|
||||
/** The prompt sections one agent's scope assembles, by name. */
|
||||
async function sectionNames(ctx: Context, agent: Agent): Promise<string[]> {
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
return assembly.sections.map(section => section.name)
|
||||
}
|
||||
|
||||
describe('dsh-tool-subagent-report', () => {
|
||||
it('registers report only in continuable child scopes', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
@@ -309,16 +327,101 @@ describe('dsh-tool-subagent-report', () => {
|
||||
const { ctx, parent, fiber } = await setup()
|
||||
const { child } = await startChild(ctx, parent)
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).toContain('report')
|
||||
expect(await sectionNames(ctx, child)).toContain('tool:report')
|
||||
|
||||
await fiber?.dispose()
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
expect((await callReport(ctx, child, 'revoked')).isError).toBe(true)
|
||||
|
||||
const late = await ctx.plugin(tool, { reportDelivery: 'quiet' })
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
await late.dispose()
|
||||
})
|
||||
|
||||
it('rolls back prompt guidance when tool registration fails', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
|
||||
expect(() => tool.installReportTool(child.ctx, ctx, 'quiet')).toThrow(/already registered in this scope/)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('aggregates a registration failure with a prompt rollback failure', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const disposeConflict = registerReportConflict(child)
|
||||
const rollbackFailure = new Error('prompt rollback listener failed')
|
||||
let promptChanges = 0
|
||||
const off = ctx.on('system-prompt/change', () => {
|
||||
promptChanges++
|
||||
if (promptChanges === 2) throw rollbackFailure
|
||||
})
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
off()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate installation failure')
|
||||
expect(failure.errors).toHaveLength(2)
|
||||
expect(String(failure.errors[0])).toContain('already registered in this scope')
|
||||
expect(failure.errors[1]).toBe(rollbackFailure)
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
disposeConflict()
|
||||
})
|
||||
|
||||
it('attempts both revocations and aggregates change-listener failures', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const dispose = tool.installReportTool(child.ctx, ctx, 'quiet')
|
||||
const toolFailure = new Error('tool removal listener failed')
|
||||
const promptFailure = new Error('prompt removal listener failed')
|
||||
const offTool = ctx.on('tools/change', () => { throw toolFailure })
|
||||
const offPrompt = ctx.on('system-prompt/change', () => { throw promptFailure })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
dispose()
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
offPrompt()
|
||||
offTool()
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected aggregate revocation failure')
|
||||
expect(failure.errors).toEqual([toolFailure, promptFailure])
|
||||
expect(ctx.tools.schemas(child).map(schema => schema.name)).not.toContain('report')
|
||||
expect(await sectionNames(ctx, child)).not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('scopes the report guidance to the child that owns it', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const { child } = await startChild(ctx, parent, 'first child')
|
||||
const { child: sibling } = await startChild(ctx, parent, 'second child')
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(child))
|
||||
const guidance = assembly.sections.find(section => section.name === 'tool:report')
|
||||
// Pins the model-visible instruction that makes the return channel a
|
||||
// contract rather than an option the child may quietly skip.
|
||||
expect(guidance?.text).toContain('Deliver your result with the report tool before you finish')
|
||||
expect(guidance?.text).toContain('reporting never ends your turn')
|
||||
|
||||
expect(await sectionNames(ctx, parent)).not.toContain('tool:report')
|
||||
// A sibling installs its own copy; neither child can observe the other's.
|
||||
expect(await sectionNames(ctx, sibling)).toContain('tool:report')
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
|
||||
.not.toContain('tool:report')
|
||||
})
|
||||
|
||||
it('rolls back materialization when a setup contribution revokes itself', async () => {
|
||||
const { ctx, parent } = await setup({ load: false })
|
||||
const self: { revoke?: () => void } = {}
|
||||
@@ -405,10 +508,29 @@ describe('dsh-tool-subagent-report', () => {
|
||||
it('keeps the namespace plugin shape and validates its default', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-report')
|
||||
expect(tool.inject).toEqual(['subagents', 'tools'])
|
||||
expect(tool.Config({}).reportDelivery).toBe('quiet')
|
||||
expect(tool.inject).toEqual(['subagents', 'tools', 'systemPrompt'])
|
||||
// Waking is the default because a report that never wakes its parent
|
||||
// cannot deliver a result to an agent that already parked.
|
||||
expect(tool.Config({}).reportDelivery).toBe('wakeup')
|
||||
expect(() => tool.Config({ reportDelivery: 'shout' } as never)).toThrow()
|
||||
})
|
||||
|
||||
it('wakes the parent under the default configuration', async () => {
|
||||
const { ctx, parent, adapter } = await setup({ config: {} })
|
||||
const { child } = await startChild(ctx, parent)
|
||||
const enqueues: string[] = []
|
||||
ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (agent === parent) {
|
||||
enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
|
||||
}
|
||||
})
|
||||
|
||||
expect((await callReport(ctx, child, 'DEFAULT_WAKES')).isError).toBe(false)
|
||||
expect(enqueues).toEqual(['queued'])
|
||||
await vi.waitFor(() => {
|
||||
expect(adapter.requests.some(request => request.sessionId === parent.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */
|
||||
@@ -427,6 +549,9 @@ describe('dsh-tool-subagent-report result independence', () => {
|
||||
expect(ctx.agents.get(started.childId) === undefined).toBe(true)
|
||||
}, { timeout: 5_000 })
|
||||
|
||||
// The parent does learn the child settled — that account is the
|
||||
// continuation service's, carried under its own `subagent-settled` source.
|
||||
// Nothing turns the child's final answer into a report it did not send.
|
||||
expect(reports(parent)).toEqual([])
|
||||
expect(userTexts((await ctx.sessionPersistence.load(started.childId)).events)).toEqual(['child task'])
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
@@ -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/tool-subagent/README.md
|
||||
README.md: 578ea4786e1d996251360c4aed92f2e882553681
|
||||
README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae
|
||||
README.md: d4c472d54f87d6006aef96079050cf14885ed8fb
|
||||
README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321
|
||||
|
||||
@@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives
|
||||
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
|
||||
|
||||
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The parent is not left guessing when to look, though: the continuation service delivers one settlement notice to it whenever a continuable child's Activation ends, which is why the schema tells the model it will be told and must not poll. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
@@ -37,7 +37,7 @@ Foreground and background calls are concurrency-safe: sibling delegations in one
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation and returns its subagent id, while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`.
|
||||
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation, returns its subagent id, and reports its own completion — so the model is told never to poll or wait on it — while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -65,11 +65,11 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode the child does not report back; an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its output.
|
||||
Start returns exactly `started subagent <childId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool.
|
||||
The acknowledgement is retained; a one-shot final output enters parent history only when collected or injected, while a continuable child's output never returns through this tool — its settlement notice arrives independently of any tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id.
|
||||
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries its closing message, but it is not this call's return value and cannot be awaited here.
|
||||
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
|
||||
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
|
||||
|
||||
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。不过父级无需猜测何时查看:每当可继续子 agent 的 Activation 结束,继续执行服务都会向父级投递一条结算通知——正因如此,schema 才告诉模型它会被通知,且不得轮询。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话并返回子 agent id 的后台子 agent,而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。
|
||||
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述;启用后台模式会添加 `run_in_background`,可继续模式描述为启动一个保留其对话、返回子 agent id 并会自行报告完成的后台子 agent——因此模型被告知绝不要轮询或等待它——而一次性模式描述为返回一个用 `task_output` 收集、用 `task_kill` 停止的后台任务 id。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -65,11 +65,11 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,子 agent 不会回报;独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其输出来源。
|
||||
在配置的可继续模式下,启动时返回内容恰为 `started subagent <childId>`;在配置的一次性模式下,则返回 `started background subagent task <id>`。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回。
|
||||
确认消息会被保留;一次性最终输出只在收集或注入时进入父级历史,而可继续子 agent 的输出绝不会通过本工具返回——其结算通知独立于任何工具结果到达。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -77,6 +77,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。
|
||||
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带其收尾消息,但它不是本次调用的返回值,也无法在此等待。
|
||||
- **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。
|
||||
- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。
|
||||
|
||||
@@ -262,12 +262,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description + (backgroundEnabled
|
||||
// The return channel is a separately installed capability this package
|
||||
// cannot observe, so this describes only this call's result.
|
||||
// The completion notice is the continuation service's own behavior, not
|
||||
// a separately installed capability, so this promise holds whenever the
|
||||
// continuable background path is reachable at all.
|
||||
? continuable
|
||||
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
|
||||
+ ' you receive only its subagent id, never its result, and it works on its own. Use this for'
|
||||
+ ' work whose result you do not need returned by this call; `send_message` sends it more work.'
|
||||
+ ' this call returns only its subagent id, and the subagent works on its own from there. You'
|
||||
+ ' are told when it finishes, so never poll or wait on it; `send_message` sends it more work.'
|
||||
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
@@ -286,7 +287,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
type: 'boolean' as const,
|
||||
description: continuable
|
||||
? 'Run as a background subagent that keeps its conversation and return only its subagent id. '
|
||||
+ 'This call never returns its result; send it more work with send_message.'
|
||||
+ 'This call does not wait for it; you are told when it finishes. Send it more work with send_message.'
|
||||
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
|
||||
@@ -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/support/acp-snapshot/README.md
|
||||
README.md: 8a9669b1f555fceb7af6d017d273b62d7dcad1e1
|
||||
README.zh.md: efa31df86d3b494ab14fc3742fc84adce570a593
|
||||
README.md: 06f1cb67cfcd954254db480ea696d10d81b37438
|
||||
README.zh.md: c4a5643f8c5d7e5b62a160cd32e5969187d34046
|
||||
|
||||
@@ -55,6 +55,8 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove
|
||||
|
||||
A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes.
|
||||
|
||||
A child session whose own scope composes a different request declares it per fixture index: `pinsChildToolSchemas` moves that child's tool sequence into `tool-schemas.<n>.expected.json`, and `pinsChildSystemPrompts` moves its prompt into `system-prompt.<n>.expected.md`. Each names the `session.<n>.jsonl` fixture it describes, leaves every other request-header field to the class pin, and requires its sidecar to exist exactly when declared. A child prompt sidecar must also differ from its class pin, so a redundant copy fails instead of drifting. A continuable child carrying the scope-local `report` tool and its guidance section is the shipped case for both.
|
||||
|
||||
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. A scenario whose composition needs a usable `pwsh` declares `pwshOnly`; the caller-supplied `hasPwsh` probe (the shipped acp-agent suite follows the executor's own resolution, so Program Files installs count) skips the run test when no usable `pwsh` resolves while the fixture guards keep covering its committed files everywhere.
|
||||
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
|
||||
@@ -55,6 +55,8 @@ defineAcpSnapshotSuite({
|
||||
|
||||
每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。
|
||||
|
||||
自身作用域组合出不同请求的 child 会话按 fixture 索引单独声明:`pinsChildToolSchemas` 把该 child 的工具序列移入 `tool-schemas.<n>.expected.json`,`pinsChildSystemPrompts` 把其提示词移入 `system-prompt.<n>.expected.md`。两者都指名自己描述的 `session.<n>.jsonl` fixture,其余请求 header 字段仍归类别 pin 所有,并要求 sidecar 恰好在声明时存在。child 提示词 sidecar 还必须与其类别 pin 不同,因此冗余副本会直接失败,而不会悄悄漂移。携带作用域局部 `report` 工具及其指引 section 的可继续 child 是两者的随附用例。
|
||||
|
||||
每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。组合需要可用 `pwsh` 的场景声明 `pwshOnly`;调用方提供的 `hasPwsh` 探测(随附的 acp-agent 套件遵循执行器自身的解析,因此 Program Files 安装也计入)在解析不到可用 `pwsh` 时跳过运行测试,而 fixture 保护仍处处覆盖其已提交文件。
|
||||
|
||||
示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。
|
||||
|
||||
@@ -46,6 +46,11 @@ function childToolSchemasSnapshot(index: number): string {
|
||||
return `tool-schemas.${index}.expected.json`
|
||||
}
|
||||
|
||||
/** Return the dedicated system-prompt sidecar for one child fixture index. */
|
||||
function childSystemPromptSnapshot(index: number): string {
|
||||
return `system-prompt.${index}.expected.md`
|
||||
}
|
||||
|
||||
/** The optional full Windows-native stdout transcript. */
|
||||
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
|
||||
|
||||
@@ -116,6 +121,13 @@ export interface Scenario {
|
||||
* request-header field.
|
||||
*/
|
||||
pinsChildToolSchemas?: readonly number[]
|
||||
/**
|
||||
* Child fixture indices whose own system prompt is pinned separately, where
|
||||
* `1` names `session.1.jsonl` and `system-prompt.1.expected.md`. A child
|
||||
* scope that installs its own prompt section (the continuable `report`
|
||||
* guidance) composes a prompt the class pin cannot describe.
|
||||
*/
|
||||
pinsChildSystemPrompts?: readonly number[]
|
||||
/**
|
||||
* How many changed `request/header` snapshots this PINNING scenario's primary
|
||||
* fixture legitimately carries (default 0). Their full prompt text is kept in
|
||||
@@ -491,6 +503,18 @@ export function formatSystemPromptSnapshot(
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a child prompt sidecar that cannot own distinct, canonical prompt text.
|
||||
* @param sidecar - committed child prompt snapshot.
|
||||
* @param classPin - initial prompt snapshot owned by the scenario's header class.
|
||||
* @param label - repository-relative fixture label for diagnostics.
|
||||
*/
|
||||
export function assertChildSystemPromptSnapshot(sidecar: string, classPin: string, label: string): void {
|
||||
if (sidecar.trim().length === 0) throw new Error(`${label} must pin a non-empty prompt`)
|
||||
if (!sidecar.endsWith('\n')) throw new Error(`${label} must end in a newline`)
|
||||
if (sidecar === classPin) throw new Error(`${label} must differ from its class pin`)
|
||||
}
|
||||
|
||||
/** Return the initial-prompt portion of a possibly multi-header snapshot. */
|
||||
function initialSystemPromptSnapshot(snapshot: string): string {
|
||||
const marker = snapshot.indexOf('\n<!-- request/header change ')
|
||||
@@ -1192,6 +1216,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
}
|
||||
|
||||
const childSchemaPins = new Set(scenario.pinsChildToolSchemas ?? [])
|
||||
const childPromptPins = new Set(scenario.pinsChildSystemPrompts ?? [])
|
||||
|
||||
// Record writes live model fixtures; keyless refresh writes every comparable replayed
|
||||
// fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars.
|
||||
@@ -1281,6 +1306,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
schemaSets.slice(1),
|
||||
))
|
||||
}
|
||||
for (const index of childPromptPins) {
|
||||
const log = result.sessionLogs[index]
|
||||
expect(log, `${mode}: no child session log at index ${index} to snapshot a prompt from`)
|
||||
.toBeDefined()
|
||||
const prompts = normalizedSystemPrompts((log as HarvestedLog).content, ctx)
|
||||
expect(prompts.length, `${mode}: child ${index} produced no system prompt to snapshot`)
|
||||
.toBeGreaterThan(0)
|
||||
await writeFile(
|
||||
join(dir, childSystemPromptSnapshot(index)),
|
||||
formatSystemPromptSnapshot(prompts[0] as string),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const expected of stdoutExpectedVariants(scenario)) {
|
||||
@@ -1340,6 +1377,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
const parsed = parseToolSchemasSnapshot(sidecar)
|
||||
childPinnedSchemas.set(index, [parsed.initial, ...parsed.changes])
|
||||
}
|
||||
const childPinnedPrompts = new Map<number, string>()
|
||||
for (const index of childPromptPins) {
|
||||
childPinnedPrompts.set(
|
||||
index,
|
||||
await readFile(join(dir, childSystemPromptSnapshot(index)), 'utf8'),
|
||||
)
|
||||
}
|
||||
for (const [logIndex, log] of result.sessionLogs.entries()) {
|
||||
const childSchemas = childPinnedSchemas.get(logIndex)
|
||||
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
|
||||
@@ -1366,8 +1410,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
|
||||
.toEqual(expected)
|
||||
if (expectedChanges === 0) {
|
||||
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
|
||||
.toEqual(initialPromptSnapshot)
|
||||
// A pinned child owns its whole prompt: its scope-local sections
|
||||
// are exactly what the class pin cannot describe.
|
||||
const childPrompt = childPinnedPrompts.get(logIndex)
|
||||
const promptOrigin = childPrompt === undefined
|
||||
? `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`
|
||||
: childSystemPromptSnapshot(logIndex)
|
||||
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptOrigin}`)
|
||||
.toEqual(childPrompt ?? initialPromptSnapshot)
|
||||
}
|
||||
}
|
||||
if (scenario.pinsHeader === true && logIndex === 0) {
|
||||
@@ -1400,16 +1450,19 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
|
||||
it('every registered scenario has its required fixture files', async () => {
|
||||
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
|
||||
for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas } of scenarios) {
|
||||
for (const { name, overridden, pinsNativeWindowsStdout, pinsChildToolSchemas, pinsChildSystemPrompts } of scenarios) {
|
||||
const dir = join(snapshotsDir, name)
|
||||
const declaredChildPins = new Set(pinsChildToolSchemas ?? [])
|
||||
const childSidecars = (await readdir(dir, { withFileTypes: true }))
|
||||
const files = (await readdir(dir, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => /^tool-schemas\.([1-9]\d*)\.expected\.json$/.exec(entry.name))
|
||||
.map(entry => entry.name)
|
||||
const childIndices = (pattern: RegExp): Set<number> => new Set(files
|
||||
.map(file => pattern.exec(file))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map(match => Number(match[1]))
|
||||
expect(new Set(childSidecars), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``)
|
||||
.toEqual(declaredChildPins)
|
||||
.map(match => Number(match[1])))
|
||||
expect(childIndices(/^tool-schemas\.([1-9]\d*)\.expected\.json$/), `${name}: child tool-schema sidecars must match \`pinsChildToolSchemas\``)
|
||||
.toEqual(new Set(pinsChildToolSchemas ?? []))
|
||||
expect(childIndices(/^system-prompt\.([1-9]\d*)\.expected\.md$/), `${name}: child system-prompt sidecars must match \`pinsChildSystemPrompts\``)
|
||||
.toEqual(new Set(pinsChildSystemPrompts ?? []))
|
||||
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
|
||||
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
|
||||
expect(
|
||||
@@ -1492,13 +1545,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
assertUniqueSnapshotContents('tool-schema', schemas)
|
||||
})
|
||||
|
||||
it('every declared child tool-schema sidecar is canonical and names a real child', async () => {
|
||||
it('every declared child sidecar is canonical and names a real child', async () => {
|
||||
for (const scenario of scenarios) {
|
||||
const pins = scenario.pinsChildToolSchemas ?? []
|
||||
if (pins.length === 0) continue
|
||||
const dir = join(snapshotsDir, scenario.name)
|
||||
const files = await sessionFixtures(dir)
|
||||
for (const index of pins) {
|
||||
for (const index of scenario.pinsChildToolSchemas ?? []) {
|
||||
expect(files[index], `${scenario.name}: child schema pin ${index} must name an existing session.<n>.jsonl fixture`)
|
||||
.toBeDefined()
|
||||
const file = childToolSchemasSnapshot(index)
|
||||
@@ -1509,6 +1560,16 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
expect(parsed.initial.length, `${scenario.name}/${file} must pin at least one schema`)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
for (const index of scenario.pinsChildSystemPrompts ?? []) {
|
||||
expect(files[index], `${scenario.name}: child prompt pin ${index} must name an existing session.<n>.jsonl fixture`)
|
||||
.toBeDefined()
|
||||
const file = childSystemPromptSnapshot(index)
|
||||
const sidecar = await readFile(join(dir, file), 'utf8')
|
||||
/* v8 ignore next -- registration guarantees every scenario class has resolved sources. */
|
||||
const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario
|
||||
const classPin = await readFile(join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
|
||||
assertChildSystemPromptSnapshot(sidecar, initialSystemPromptSnapshot(classPin), `${scenario.name}/${file}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
]},
|
||||
{ "file": "b/child/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
|
||||
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nCHILD GUIDANCE", "tools": [{ "name": "child-only", "description": "Child D", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
3
packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/system-prompt.1.expected.md
vendored
Normal file
3
packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/system-prompt.1.expected.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
SYS PROMPT
|
||||
|
||||
CHILD GUIDANCE
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type Scenario,
|
||||
} from '../src/index.ts'
|
||||
import {
|
||||
assertChildSystemPromptSnapshot,
|
||||
assertUniqueSnapshotContents,
|
||||
claimSharedSnapshot,
|
||||
fixtureContext,
|
||||
@@ -85,6 +86,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
|
||||
configPath: AGENT.configPath,
|
||||
workspaceParent: tmpdir(),
|
||||
pinsChildToolSchemas: [1],
|
||||
pinsChildSystemPrompts: [1],
|
||||
prepareWorkspace: (cwd) => {
|
||||
writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
|
||||
},
|
||||
@@ -126,6 +128,7 @@ function staleRefreshFixtures(dir: string): void {
|
||||
writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n')
|
||||
writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
|
||||
writeFileSync(join(dir, 'plain-turn', 'tool-schemas.1.expected.json'), '{"initial":[{"name":"stale-child"}],"changes":[]}\n')
|
||||
writeFileSync(join(dir, 'plain-turn', 'system-prompt.1.expected.md'), 'STALE CHILD PROMPT\n')
|
||||
|
||||
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
|
||||
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
|
||||
@@ -191,6 +194,8 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
|
||||
const childSchemas = readFileSync(join(refreshDir, 'plain-turn', 'tool-schemas.1.expected.json'), 'utf8')
|
||||
expect(childSchemas).toContain('"name": "child-only"')
|
||||
expect(childSchemas).not.toContain('stale-child')
|
||||
const childPrompt = readFileSync(join(refreshDir, 'plain-turn', 'system-prompt.1.expected.md'), 'utf8')
|
||||
expect(childPrompt).toBe('SYS PROMPT\n\nCHILD GUIDANCE\n')
|
||||
|
||||
const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8')
|
||||
expect(pinSession).toContain('"cwd":"{{cwd}}"')
|
||||
@@ -591,6 +596,34 @@ describe('formatSystemPromptSnapshot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertChildSystemPromptSnapshot', () => {
|
||||
const label = 'plain-turn/system-prompt.1.expected.md'
|
||||
|
||||
it('accepts a distinct non-empty canonical child prompt', () => {
|
||||
expect(() => {
|
||||
assertChildSystemPromptSnapshot('SYS PROMPT\n\nCHILD GUIDANCE\n', 'SYS PROMPT\n', label)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an empty or non-canonical child prompt', () => {
|
||||
expect(() => { assertChildSystemPromptSnapshot('\n', 'SYS PROMPT\n', label) }).toThrow(/non-empty prompt/)
|
||||
expect(() => {
|
||||
assertChildSystemPromptSnapshot('CHILD GUIDANCE', 'SYS PROMPT\n', label)
|
||||
}).toThrow(/end in a newline/)
|
||||
})
|
||||
|
||||
it('rejects a child prompt that duplicates its class pin', () => {
|
||||
const classSnapshot = readFileSync(join(REPLAY_DIR, 'pin-turn', 'system-prompt.expected.md'), 'utf8')
|
||||
const marker = classSnapshot.indexOf('\n<!-- request/header change ')
|
||||
expect(marker).toBeGreaterThan(0)
|
||||
const initialClassPin = classSnapshot.slice(0, marker)
|
||||
|
||||
expect(() => {
|
||||
assertChildSystemPromptSnapshot(initialClassPin, initialClassPin, label)
|
||||
}).toThrow(/must differ from its class pin/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('headerChangeCount', () => {
|
||||
it('counts changed request headers, ignoring anchors, blanks, and other lines', () => {
|
||||
const change = JSON.stringify({ type: 'request/header', seq: 2, time: 9, data: { reason: 'change' } })
|
||||
|
||||
Reference in New Issue
Block a user