Merge refreshed typed Code Mode results into result card fix

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt
This commit is contained in:
Tianyi Cui
2026-07-22 22:17:05 +08:00
473 changed files with 17962 additions and 3441 deletions

View File

@@ -11,10 +11,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
@@ -22,9 +22,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |

View File

@@ -109,10 +109,10 @@ export class LocalBashExecutor extends BashExecutor {
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
// Carry a sandbox-mode override through verbatim: this executor never
// Carry a sandbox policy through verbatim: this executor never
// confines, so the field is inert here (the seam contract) — a
// sandboxing subclass overrides resolve() to stamp its default instead.
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}

View File

@@ -16,7 +16,7 @@ Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -29,12 +29,12 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: read-only
workspaceRoot: !!js process.cwd()
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
```
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
## Model Experience

View File

@@ -3,14 +3,15 @@
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
* processes carry `runnerFailed`. The tool owns approval and passes a complete
* per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
@@ -18,21 +19,19 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and the `workspace-write` boundary root — is NOT here: it
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
* home both enforcing families read, so bash and fs can never confine to
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
* config, not this executor's.
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for both enforcing families. The runner
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
*/
export type Config = LocalConfig
/**
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. The policy default (mode + workspace root) is the fallback,
* while a session override or approved one-shot escalation may select each
* call's mode. The prompt does not state the standing mode; `result.sandbox`
* reports the mode and enforcement actually used.
* unchanged. Tool calls pass the calling session's resolved policy; direct
* calls fall back to deployment policy. The prompt does not state the standing
* mode; `result.sandbox` reports the mode and enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox', 'sandboxPolicy']
@@ -42,7 +41,6 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
private readonly workspaceRoot: string
/**
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
@@ -58,11 +56,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// The sandbox default (mode + workspaceRoot) is the one shared policy home
// both enforcing families read; injecting sandboxPolicy guarantees it is
// constructed first. workspaceRoot arrives already resolved absolute.
// The default mode is the capability fact used for schema advertisement;
// actual tool executions carry their resolved per-call policy.
this.mode = ctx.sandboxPolicy.defaultMode
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
}
/** The configured default mode — the capability fact the tool layer reads. */
@@ -71,24 +67,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
/**
* Stamp the effective mode onto the spec — the request's explicit override
* (an approved escalation), else this executor's configured default — so
* defaulting stays an explicit resolve step and `run()`/`start()` read the
* spec, never the config.
* Stamp a complete per-call policy onto the spec. Tool calls supply the
* calling session's resolved mode and root; lower-level callers fall back to
* the deployment policy.
*/
override resolve(request: BashExecRequest): BashExecSpec {
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
// resolve() always stamps the mode; the cast records that invariant
// (mirrors the constructor's config casts).
const mode = spec.sandboxMode as SandboxMode
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
const result = await super.run(spec)
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec.command, mode)
const confined = this.confine(spec.command, { ...policy, mode })
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
@@ -99,11 +93,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
override start(spec: BashExecSpec): BashProcess {
// Same stamped-by-resolve invariant as run().
const mode = spec.sandboxMode as SandboxMode
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Install facts synchronously; promise settlement cannot run before start() returns.
const confined = this.confine(spec.command, mode)
const confined = this.confine(spec.command, { ...policy, mode })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
@@ -138,13 +132,13 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
*/
private confine(command: string, mode: ConfinedSandboxMode): {
private confine(command: string, policy: SandboxPolicy): {
command: string
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
} {
const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
return {
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
enforcement: confined.enforcement,

View File

@@ -89,7 +89,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')

View File

@@ -94,7 +94,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')

View File

@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
@@ -72,6 +72,10 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult {
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
}
function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
return { mode, workspaceRoot }
}
describe('the provider hand-off', () => {
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
const { bash, calls } = await setup()
@@ -147,30 +151,31 @@ describe('danger-full-access', () => {
})
})
describe('per-call sandboxMode override (the escalation mechanism)', () => {
describe('per-call sandbox policy (the session and escalation carrier)', () => {
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
const { bash } = await setup()
expect(bash.sandboxMode).toBe('read-only')
expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only'))
})
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => {
const { bash, calls } = await setup()
expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
const explicit = executionPolicy('workspace-write', '/session/project')
expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
await bash.run(bash.resolve({ command: 'true' }))
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
})
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
const { bash } = await setup()
const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
})
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
const { bash, calls } = await setup()
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
expect(result.stdout.text).toBe('free\n')
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(calls).toHaveLength(0)
@@ -181,7 +186,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
// once — anything keyed off the configured default would misreport the
// escalated one at its settle stamp.
const { bash } = await setup()
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
const plain = bash.start(bash.resolve({ command: 'true' }))
await plain.done
await escalated.done
@@ -191,7 +196,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
const { bash, calls } = await setup()
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(task.readOutput().delta).toContain('bg-free')

View File

@@ -91,7 +91,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')

View File

@@ -27,7 +27,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).

View File

@@ -4,7 +4,7 @@
* @module dsh-bash/types
*/
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
export const DSH_ENV_PREFIX = 'DSH_' as const
@@ -75,8 +75,8 @@ export interface BashExecRequest {
* reject non-`DSH_*` names supplied through this managed channel.
*/
dshEnv?: DshEnvironment | undefined
/** Explicit per-call sandbox mode override. */
sandboxMode?: SandboxMode | undefined
/** Fully resolved per-call sandbox policy; sandboxing executors default it. */
sandboxPolicy?: SandboxExecutionPolicy | undefined
}
/**
@@ -106,8 +106,8 @@ export interface BashExecSpec {
env?: Record<string, string> | undefined
/** Managed `DSH_*` snapshot; implementations reject ordinary names. */
dshEnv?: DshEnvironment | undefined
/** Resolved sandbox mode; ignored by executors that do not confine. */
sandboxMode: SandboxMode | undefined
/** Resolved sandbox policy; ignored by executors that do not confine. */
sandboxPolicy: SandboxExecutionPolicy | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */

View File

@@ -17,7 +17,7 @@ class StubExecutor extends BashExecutor {
timeoutMs: request.timeoutMs ?? 1000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}
@@ -55,7 +55,7 @@ describe('BashExecutor service seam', () => {
const ctx = new Context()
await ctx.plugin(StubExecutor)
const spec = ctx.bash.resolve({ command: 'echo hi' })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxMode: undefined })
expect(spec).toEqual({ command: 'echo hi', workdir: '/stub', timeoutMs: 1000, stdoutMaxBytes: 64_000, sandboxPolicy: undefined })
const result = await ctx.bash.run(spec)
expect(result.exitCode).toBe(0)

View File

@@ -17,12 +17,12 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
| `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. |
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
| `workdir` | string | Working directory for this call. Defaults to the filesystem identity of the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. When sandbox policy is present, the tool reuses its already-canonical `workspaceRoot` as the workdir base so confinement and process launch cannot resolve the same session spelling differently.
### Managed shell environment

View File

@@ -19,9 +19,9 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -300,11 +300,18 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
}
/**
* Resolve an explicit workdir first, making a relative one session-cwd-relative;
* otherwise use the session cwd and leave executor defaulting as the fallback.
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
* otherwise use the filesystem identity of the session cwd and leave executor
* defaulting as the fallback. A resolved sandbox-policy root wins so workdir
* and confinement use the exact same per-call identity.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
function resolveWorkdir(
modelWorkdir: string | undefined,
exec: { agent?: Agent },
policyWorkspaceRoot?: string,
): string | undefined {
const headerCwd = exec.agent?.session.header.cwd
const sessionCwd = policyWorkspaceRoot ?? (headerCwd === undefined ? undefined : canonicalPath(headerCwd))
if (modelWorkdir === undefined) return sessionCwd
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
return resolvePath(sessionCwd, modelWorkdir)
@@ -363,9 +370,14 @@ export function apply(ctx: Context, config: Config = {}): void {
const backgroundEnabled = config.enableRunInBackground ?? true
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && sandboxPolicy === undefined) {
throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
@@ -375,14 +387,19 @@ export function apply(ctx: Context, config: Config = {}): void {
* guard (the fields are unadvertised without a sandboxing executor, yet
* schema validation checks advertised keys only, so an unadvertised
* `sandbox_permissions` still reaches execute) and the approval ingredients
* — the seam is consumed opportunistically (`ctx.get`) so a deployment
* without it degrades per call.
* The shared policy resolver is required whenever the executor advertises
* confinement, so a split composition fails at tool-plugin load.
*/
const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
const approveBashEscalation = (
mode: string,
justification: string,
exec: ToolExecution,
standingPolicy: SandboxExecutionPolicy | undefined,
): Promise<SandboxMode> => {
if (escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode
return approveEscalation(
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
{
@@ -493,17 +510,21 @@ export function apply(ctx: Context, config: Config = {}): void {
async execute(args: BashToolArgs, exec) {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
const workdir = resolveWorkdir(args.workdir, exec)
const standingPolicy = resolveSandboxPolicy(exec)
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
: undefined
const policy = approvedMode === undefined
? standingPolicy
: { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode }
const workdir = resolveWorkdir(args.workdir, exec, standingPolicy?.workspaceRoot)
const dshEnv = bashEnv.collect(exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
dshEnv,
...sandboxMode !== undefined ? { sandboxMode } : {},
...policy !== undefined ? { sandboxPolicy: policy } : {},
}
if (args.run_in_background === true) {
// Undeclared keys are allowed, so schema omission also needs enforcement.

View File

@@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
import { renderProcessRead, renderResult } from '../src/render.ts'
@@ -107,12 +108,12 @@ class RecordingSandboxExecutor extends BashExecutor {
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode ?? 'read-only',
sandboxPolicy: request.sandboxPolicy ?? { mode: 'read-only', workspaceRoot: process.cwd() },
}
}
run(spec: BashExecSpec): Promise<BashRunResult> {
this.modes.push(spec.sandboxMode)
this.modes.push(spec.sandboxPolicy?.mode)
return Promise.resolve({
exitCode: 0,
signal: null,
@@ -122,7 +123,7 @@ class RecordingSandboxExecutor extends BashExecutor {
stdout: { text: 'ok', truncated: false },
stderr: { text: '', truncated: false },
sandbox: {
mode: spec.sandboxMode ?? 'read-only',
mode: spec.sandboxPolicy?.mode ?? 'read-only',
denied: false,
...spec.command === 'without optional sandbox facts'
? {}
@@ -132,13 +133,13 @@ class RecordingSandboxExecutor extends BashExecutor {
}
start(spec: BashExecSpec): BashProcess {
this.modes.push(spec.sandboxMode)
this.modes.push(spec.sandboxPolicy?.mode)
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
sandbox: { mode: spec.sandboxPolicy?.mode ?? 'read-only', denied: false },
readOutput: () => ({ delta: '', lossy: false }),
kill: () => false,
}
@@ -155,7 +156,7 @@ class CountingStartExecutor extends BashExecutor {
workdir: request.workdir ?? '/x',
timeoutMs: request.timeoutMs ?? 0,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}
@@ -181,6 +182,7 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
if (withApproval) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolBash)
@@ -553,6 +555,14 @@ describe('sandbox escalation through the generic task producer', () => {
justification: 'the command needs workspace writes',
}
it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingSandboxExecutor)
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
})
it('advertises the sandbox fields and validates their pairing', async () => {
const { ctx } = await setupSandboxed()
const schema = ctx.tools.schemas().find(item => item.name === 'bash')!
@@ -1033,7 +1043,7 @@ describe('the model-facing bash tool builds its request from named args only (no
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}
run(): Promise<BashRunResult> {

View File

@@ -44,4 +44,3 @@ export function transportError<T>(error: unknown): RpcResult<T> {
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}

View File

@@ -23,7 +23,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, flatten into a bounded-depth pre-order wire value for structured clone, and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure diagnostic. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.

View File

@@ -38,7 +38,10 @@ export interface Config {
* nobody will resolve).
*/
maxWallMs?: number
/** Hard cap for the combined serialized outer logs, completion value, and failure diagnostic. */
/**
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
* fixed result-envelope syntax is excluded.
*/
maxOutputBytes?: number
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
maxOldGenerationSizeMb?: number
@@ -56,7 +59,7 @@ type ResolvedConfig = Required<Config>
*/
const ELU_POLL_INTERVAL_MS = 25
/** Smallest cap that can represent the empty logs array plus an empty JSON failure diagnostic. */
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
const MIN_OUTPUT_BYTES = 4
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */

View File

@@ -656,7 +656,7 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
})
it('requires maxOutputBytes to fit the smallest outer failure envelope', async () => {
it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
const ctx = new Context()
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
await expect(ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)

View File

@@ -4,11 +4,11 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
## Lifecycle
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
## Prompt Shape
@@ -40,15 +40,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when
</system-reminder>
```
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
@@ -61,18 +61,19 @@ export interface Config {
maxBytes: number
maxSourceBytes?: number
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
}
```
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory every existing candidate loads, and candidates whose content matches an earlier one after trimming surrounding whitespace are dropped, so with the defaults an `AGENTS.md` and a `CLAUDE.md` that share content render once (as `AGENTS.md`) while genuinely distinct siblings both apply. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads its existing overlays alongside the base files of the same directory (rendered after them) under the same per-directory dedup; an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both candidate lists only control project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
## Budgeting And Bounded Reads
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
## Model Experience
@@ -136,7 +137,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
A changed file produces `Updated instructions from: <path>` plus its replacement content. A candidate that disappears or becomes a per-directory duplicate of an earlier candidate produces the removal notice below.
##### Removal notice
@@ -160,5 +161,7 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.

View File

@@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
@@ -22,8 +23,16 @@ export interface Config {
maxBytes: number
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
maxSourceBytes?: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
/**
* Ordered same-directory project candidates; every existing file loads, with
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
*/
instructionFileCandidates?: string[]
/**
* Ordered same-directory local-overlay candidates loaded after the base files
* under the same per-directory trimmed-content dedup; empty disables the overlay.
*/
localInstructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
@@ -32,6 +41,7 @@ export const Config: z<Config> = z.object({
maxBytes: z.number().required(),
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
})
/** Normalized instruction discovery configuration. */
@@ -39,6 +49,7 @@ export interface ResolvedDiscoveryConfig {
dshHome: string
projectRootMarkers: string[]
instructionFileCandidates: string[]
localInstructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
@@ -66,17 +77,24 @@ export function resolveConfig(config: Config): ResolvedConfig {
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
instructionFileCandidates: resolveInstructionFileCandidates(
config.instructionFileCandidates,
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
),
localInstructionFileCandidates: resolveInstructionFileCandidates(
config.localInstructionFileCandidates,
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
return (candidates ?? [...fallback]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}

View File

@@ -14,3 +14,15 @@ import { createHash } from 'node:crypto'
export function instructionContentSha1(content: string): string {
return createHash('sha1').update(content).digest('hex')
}
/**
* Compute the whitespace-insensitive identity used for per-directory duplicate
* suppression. Leading and trailing whitespace is trimmed before hashing so a
* symlinked or byte-copied sibling that differs only by surrounding whitespace
* still collapses to a single rendered file.
* @param content - exact UTF-8 instruction text.
* @returns SHA-1 digest of the trimmed content.
*/
export function trimmedInstructionDigest(content: string): string {
return instructionContentSha1(content.trim())
}

View File

@@ -5,13 +5,14 @@
*/
import { createReadStream } from 'node:fs'
import { lstat, stat } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
import { trimmedInstructionDigest } from './digest.ts'
import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
@@ -32,7 +33,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
version?: FsVersion
}
/** Provider metadata for a winning scope candidate before its content is read. */
/** Provider metadata for a probed scope candidate before its content is read. */
export interface ProbedInstructionFile extends InstructionFile {
target: FsTarget
version: FsVersion
@@ -44,6 +45,7 @@ interface DiscoverOptions {
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
signal?: AbortSignal
}
@@ -86,7 +88,9 @@ function isMissingPathError(error: unknown): boolean {
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
// stat (not lstat) follows a final-component symlink so a link to a regular
// file loads; a broken link surfaces as ENOENT and is treated as absent below.
const info = await stat(path)
signal?.throwIfAborted()
if (!info.isFile()) return { kind: 'absent' }
return { kind: 'present', info: { size: info.size } }
@@ -101,25 +105,15 @@ async function fsStatFile(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<StatFileProbe> {
// TODO(instruction-symlink-race): replace this lstat -> resolve -> read
// protocol, including probeScopeInstruction below, with a provider-owned
// atomic no-follow read so the final component cannot change after validation.
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(path, undefined, signal)
signal?.throwIfAborted()
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo?.type !== 'file') return { kind: 'absent' }
// resolve() follows a final-component symlink to its target's stable identity;
// stat then classifies that target. A link to a regular file loads, while a
// missing path or non-file target (including a link to a directory) is absent.
try {
const target = await fileSystem.resolve(path, signalOptions(signal))
signal?.throwIfAborted()
const info = await fileSystem.stat(target, signal)
signal?.throwIfAborted()
if (info?.type !== 'file') return { kind: 'unavailable' }
if (info?.type !== 'file') return { kind: 'absent' }
return {
kind: 'present',
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
@@ -232,33 +226,32 @@ export function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
async function firstExistingInstructionFile(
async function allExistingInstructionFiles(
dir: string,
root: string,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
): Promise<DiscoveredInstructionFile[]> {
const found: DiscoveredInstructionFile[] = []
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const probe = await statFile(path, fileSystem, signal)
switch (probe.kind) {
case 'present':
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
...probe.info,
}
case 'absent':
found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
continue
// A missing candidate is skipped; a transient provider failure skips only
// that candidate so the remaining independent candidates still load.
case 'absent':
case 'unavailable':
return undefined
continue
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
return assertNever(probe, 'StatFileProbe')
assertNever(probe, 'StatFileProbe')
}
}
return undefined
return found
}
async function discoverInstructionFiles(
@@ -274,7 +267,7 @@ async function discoverInstructionFiles(
files.push(file)
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
switch (userGlobalProbe.kind) {
case 'present':
@@ -295,16 +288,21 @@ async function discoverInstructionFiles(
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
if (file !== undefined) addFile(file)
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
addFile(file)
}
}
}
return files
}
/**
* Discover host-visible user-global and root-to-cwd instruction candidates.
* All present candidates in each directory are returned; trimmed-content
* duplicates are collapsed later, once content is read.
* @param options - cwd, home, root marker, and candidate configuration.
* @returns de-duplicated instruction paths in model precedence order.
* @returns path-deduplicated instruction candidates in model precedence order.
*/
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
@@ -316,7 +314,7 @@ async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterabl
}
async function readBounded(
file: DiscoveredInstructionFile,
file: { absolutePath: string; target?: FsTarget; size?: number },
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
@@ -347,6 +345,33 @@ async function readBounded(
}
}
/**
* Drop later candidates whose trimmed content duplicates an earlier sibling in
* the same directory. Different directories never collapse even when identical;
* within one directory the earliest candidate in discovery order is kept and its
* original bytes are rendered. A candidate that symlinks a sibling resolves to
* the same content and collapses here like any byte-identical real file.
* @param files - loaded files in discovery order.
* @returns the retained files in the same order.
*/
export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
const keptDigestsByDir = new Map<string, Set<string>>()
const kept: LoadedInstructionFile[] = []
for (const file of files) {
const dir = dirname(file.displayPath)
let digests = keptDigestsByDir.get(dir)
if (digests === undefined) {
digests = new Set()
keptDigestsByDir.set(dir, digests)
}
const digest = trimmedInstructionDigest(file.content)
if (digests.has(digest)) continue
digests.add(digest)
kept.push(file)
}
return kept
}
/**
* Discover, read, and render the baseline instruction chain.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
@@ -386,18 +411,19 @@ export async function loadBaselineInstructionSet(
})
}
}
if (loaded.length === 0) return undefined
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) }
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
}
/**
* Probe the current first-winning instruction candidate for one logical scope.
* @param scope - `user-global`, `.`, or a project-relative directory.
* Probe the current provider metadata for one per-candidate instruction scope.
* @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
* @param projectRoot - project root used to resolve and display project scopes.
* @param resolved - normalized plugin configuration.
* @param fileSystem - provider used for no-follow probing.
* @param fileSystem - provider used to resolve and stat scope candidates.
* @param signal - cancellation for provider probes.
* @returns present metadata, confirmed absence, or temporary unavailability.
*/
@@ -408,40 +434,32 @@ export async function probeScopeInstruction(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
const { directory, candidateName } = decodeScopeKey(scope)
const dir = directory === USER_GLOBAL_DIRECTORY
? resolved.dshHome
: scope === '.' ? projectRoot : join(projectRoot, scope)
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
for (const candidate of candidates) {
const absolutePath = join(dir, candidate)
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(absolutePath, undefined, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo === undefined || pathInfo.type !== 'file') continue
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (info?.type !== 'file') return { kind: 'unavailable' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
: directory === '.' ? projectRoot : join(projectRoot, directory)
const absolutePath = join(dir, candidateName)
// resolve() follows a final-component symlink; stat then classifies the target.
// A non-file target (missing, or a link to a directory) is a confirmed absence;
// only a provider exception is reported as unavailable.
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
return { kind: 'absent' }
if (info?.type !== 'file') return { kind: 'absent' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
}
/**

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/render
*/
import { dirname } from 'node:path'
import { basename, dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
@@ -33,7 +33,6 @@ export interface WorkspaceInstructionChange {
action: 'set' | 'replace' | 'remove'
scope: string
path: string
previousPath?: string
digest?: string
}
@@ -62,8 +61,8 @@ function truncateUtf8(value: string, maxBytes: number): string {
function escapeInstructionContent(content: string): string {
// TODO(instruction-frame-paths): apply the same delimiter neutralization to
// every interpolated path, scope, and previous path; repository-controlled
// names can otherwise close the plugin-owned system-reminder frame.
// every interpolated path and scope; repository-controlled names can
// otherwise close the plugin-owned system-reminder frame.
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
}
@@ -71,16 +70,65 @@ function sectionText(file: LoadedInstructionFile): string {
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
}
/** Directory component that identifies the single user-global instruction scope. */
export const USER_GLOBAL_DIRECTORY = 'user-global'
/**
* File name of the single user-global instruction file under `$DSH_HOME`.
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
* candidate component) both key on this name, so it lives in one place: were the
* two to disagree, the user-global instruction would load but never reconcile.
*/
export const USER_GLOBAL_FILE = 'AGENTS.md'
/**
* Derive the logical instruction scope from a model-facing path.
* @param displayPath - project-relative or user-global instruction path.
* @returns `user-global`, `.`, or the containing project-relative directory.
*/
export function scopeForDisplayPath(displayPath: string): string {
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
return dirname(displayPath)
}
const SCOPE_SEPARATOR = '\u0000'
/**
* Compose the reconciliation key for one instruction candidate file.
* Each loaded candidate is tracked independently, so the key pairs the logical
* directory with the exact candidate file name behind a NUL separator that no
* directory path or file name can contain. Distinct candidates in one directory
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
* never collide in the scope-keyed state maps.
* @param directory - `user-global`, `.`, or a project-relative directory.
* @param candidateName - instruction file name within that directory.
* @returns the per-candidate logical scope key.
*/
export function candidateScopeKey(directory: string, candidateName: string): string {
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
}
/**
* Derive the per-candidate scope key for a loaded instruction file.
* @param displayPath - project-relative or user-global instruction path.
* @returns the scope key pairing the file's directory with its name.
*/
export function instructionScopeKey(displayPath: string): string {
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
}
/**
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
* @param scope - a per-candidate scope key.
* @returns the directory scope and the candidate file name within it.
*/
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
const separator = scope.indexOf(SCOPE_SEPARATOR)
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
if (separator < 0) return { directory: scope, candidateName: '' }
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
}
function additionalSectionText(file: LoadedInstructionFile): string {
const scope = scopeForDisplayPath(file.displayPath)
return [
@@ -100,13 +148,10 @@ function changedSectionText(item: ChangeRenderItem): string {
if (change.action === 'remove') {
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
}
const description = change.previousPath === undefined
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.`
return [
`Updated instructions from: ${change.path}`,
'',
description,
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
escapeInstructionContent(file.content),
].join('\n')

View File

@@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
import {
ancestorChain,
descendantDirsBetween,
@@ -21,8 +21,12 @@ import {
type LoadedInstructionFile,
} from './files.ts'
import {
candidateScopeKey,
decodeScopeKey,
instructionScopeKey,
renderInstructionChanges,
scopeForDisplayPath,
USER_GLOBAL_DIRECTORY,
USER_GLOBAL_FILE,
type ChangeRenderItem,
type WorkspaceInstructionChange,
} from './render.ts'
@@ -44,6 +48,11 @@ export interface InstructionVersionState {
path: string
version: FsVersion
digest: string
/**
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
* per-directory duplicates on the metadata fast path without re-reading a sibling.
*/
trimmedDigest: string
}
/** Session-isolated fast-path state keyed by logical instruction scope. */
@@ -71,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
action: change.action,
scope: change.scope,
path: change.path,
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
@@ -112,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
if (value.digest !== undefined && typeof value.digest !== 'string') continue
changes.push({
action: value.action,
scope: value.scope,
path: value.path,
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
...value.digest !== undefined ? { digest: value.digest } : {},
})
}
@@ -129,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
return a.action === b.action
&& a.scope === b.scope
&& a.path === b.path
&& a.previousPath === b.previousPath
&& a.digest === b.digest
}
@@ -169,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
const digest = instructionContentSha1(file.content)
const change: WorkspaceInstructionChange = {
action: 'set',
scope: scopeForDisplayPath(file.displayPath),
scope: instructionScopeKey(file.displayPath),
path: file.displayPath,
digest,
}
changes.set(change.scope, change)
if (file.version !== undefined) {
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
versions.set(change.scope, {
path: file.displayPath,
version: file.version,
digest,
trimmedDigest: trimmedInstructionDigest(file.content),
})
}
}
return { changes, versions }
@@ -391,34 +401,67 @@ export async function reconcileInstructionContext(
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
if (options.includeBaselineScopes) {
scopes.add('user-global')
for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
const addDirScopes = (directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
}
const addProjectScopes = (dir: string): void => {
addDirScopes(relativeScope(projectRoot, dir))
}
if (options.includeBaselineScopes) {
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
}
for (const scope of effective.keys()) {
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(directory)
}
for (const scope of effective.keys()) scopes.add(scope)
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
}
const versions = versionStatesFor(session, versionCache)
const seenAbsolutePaths = new Set<string>()
// Per-directory trimmed-content identities kept so far this pass, iterated in
// candidate order (base before local); a later sibling matching an earlier one
// is a duplicate and is dropped or removed rather than rendered twice.
const keptTrimmedByDir = new Map<string, Set<string>>()
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
let digests = keptTrimmedByDir.get(directory)
if (digests === undefined) {
digests = new Set()
keptTrimmedByDir.set(directory, digests)
}
if (digests.has(digest)) return true
digests.add(digest)
return false
}
const items: ChangeRenderItem[] = []
const versionUpdates: InstructionVersionUpdate[] = []
const pushRemoval = (scope: string, path: string): void => {
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
for (const scope of scopes) {
const { directory } = decodeScopeKey(scope)
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') continue
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') {
versions.delete(scope)
continue
if (probe.kind === 'unavailable') {
// Last-good-state: the candidate stays effective, so its cached trimmed
// digest must keep occupying the directory's dedup slot — otherwise an
// identical later sibling would be emitted as a duplicate `set` until the
// next successful reconciliation removed it again.
const cached = versions.get(scope)
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
registerKeptTrimmed(directory, cached.trimmedDigest)
}
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
items.push({
change,
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
})
versionUpdates.push({ change })
continue
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
@@ -433,29 +476,39 @@ export async function reconcileInstructionContext(
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) continue
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
? previous.path
: undefined
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
...previousPath === undefined ? {} : { previousPath },
digest: currentDigest,
}
items.push({ change, file })

View File

@@ -12,6 +12,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
import { candidateScopeKey } from '../src/render.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -112,7 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'context/message'
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')

View File

@@ -14,7 +14,7 @@ Canonical successes are the inspection string, mount `{ id, pluginName, state, p
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config

View File

@@ -263,12 +263,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */',
},
{
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
},
{
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
},
],
},
@@ -372,6 +372,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'planMode',
summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
methods: [
{
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
},
{
signature: 'set(agent: Agent, active: boolean): void',
jsDoc: '/**\n * Select whether plan mode should be active from the next turn boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */',
},
],
},
{
key: 'sandbox',
summary: 'Abstract process-sandbox service.',
@@ -385,7 +399,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sandboxPolicy',
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
methods: [],
methods: [
{
signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy',
jsDoc: '/**\n * Resolve the complete policy for one capability call. An approved explicit\n * mode outranks the session\'s last `sandbox/mode` event, which outranks the\n * deployment default. A session cwd is its workspace-write boundary; the\n * configured root is the fallback for agentless calls and sessions without a\n * cwd.\n * @param request - optional session and approved mode override.\n * @returns the fully resolved per-call mode and absolute workspace root.\n */',
},
],
},
{
key: 'sessionPersistence',
@@ -1098,7 +1117,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
},
{
name: 'AskUserQuestionOption',
@@ -1134,11 +1153,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BashExecRequest',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'BashExecSpec',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'BashProcess',
@@ -1492,13 +1511,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SandboxEnforcement',
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
},
{
name: 'SandboxExecutionPolicy',
declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}',
},
{
name: 'SandboxMode',
declaration: 'export type SandboxMode = \'read-only\' | \'workspace-write\' | \'danger-full-access\';',
},
{
name: 'SandboxPolicy',
declaration: 'export interface SandboxPolicy {\n mode: ConfinedSandboxMode;\n workspaceRoot: string;\n}',
declaration: 'export interface SandboxPolicy extends SandboxExecutionPolicy {\n mode: ConfinedSandboxMode;\n}',
},
{
name: 'SandboxPolicyRequest',
declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}',
},
{
name: 'SaveTextSpill',

View File

@@ -59,45 +59,105 @@ function hasPlainArrayPrototype(value: unknown[]): boolean {
}
/* jscpd:ignore-end */
/** Where one cloned JSON value is installed. */
type CloneDestination =
| { kind: 'root' }
| { kind: 'array'; target: unknown[]; index: number }
| { kind: 'object'; target: Record<string, unknown>; key: string }
/** Deferred work for stack-safe cross-realm JSON cloning. */
type CloneTask =
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
| { kind: 'leave'; source: object }
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string, seen = new Set<object>()): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number') {
if (Number.isFinite(value) && !Object.is(value, -0)) return value
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
if (typeof value !== 'object') throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (seen.has(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
seen.add(value)
try {
if (Array.isArray(value)) {
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
const output: unknown[] = []
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
output.push(cloneJson(value[index], `${path}[${index}]`, seen))
}
return output
function cloneJson(value: unknown, path: string): unknown {
const ancestors = new Set<object>()
let root: unknown
const assign = (destination: CloneDestination, item: unknown): void => {
if (destination.kind === 'root') {
root = item
return
}
if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (destination.kind === 'array') {
destination.target[destination.index] = item
return
}
Object.defineProperty(destination.target, destination.key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
const reject = (at: string): never => {
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
}
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.source)
continue
}
if (task.kind === 'array-item') {
if (!Object.hasOwn(task.source, task.index)) reject(task.path)
tasks.push({
kind: 'visit',
value: task.source[task.index],
path: `${task.path}[${task.index}]`,
destination: { kind: 'array', target: task.target, index: task.index },
})
continue
}
const current = task.value
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
assign(task.destination, current)
continue
}
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
assign(task.destination, current)
continue
}
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
const output: unknown[] = []
assign(task.destination, output)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = current.length - 1; index >= 0; index--) {
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
}
continue
}
if (!isPlainRecord(current)) reject(task.path)
const record = current as Record<string, unknown>
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
reject(task.path)
}
const output: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
Object.defineProperty(output, key, {
value: cloneJson(entry, `${path}.${key}`, seen),
enumerable: true,
configurable: true,
writable: true,
assign(task.destination, output)
ancestors.add(record)
tasks.push({ kind: 'leave', source: record })
const entries = Object.entries(record)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'visit',
value: entry[1],
path: `${task.path}.${entry[0]}`,
destination: { kind: 'object', target: output, key: entry[0] },
})
}
return output
} finally {
seen.delete(value)
}
return root
}
/** Copy and realm-materialize the shared annotation vocabulary. */
@@ -162,111 +222,229 @@ function normalizeRequiredNames(value: unknown, properties: Record<string, unkno
return names
}
/** Normalize one implicit property map. */
/** Mutable holder used only while one normalized property-map root is unresolved. */
interface NormalizeRoot {
value?: Record<string, unknown>
}
/** Where a normalized value node is installed. */
type NormalizeValueDestination =
| { kind: 'property'; target: Record<string, unknown>; key: string }
| { kind: 'item'; target: Record<string, unknown> }
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
/** Where a normalized property map is installed. */
type NormalizeMapDestination =
| { kind: 'root'; holder: NormalizeRoot }
| { kind: 'properties'; target: Record<string, unknown> }
/** Deferred work for stack-safe sandbox schema normalization. */
type NormalizeTask =
| {
kind: 'map'
entries: Record<string, unknown>
path: string
requiredNames: ReadonlySet<string>
raw: boolean
destination: NormalizeMapDestination
}
| {
kind: 'value'
value: unknown
path: string
forceRequired: boolean
raw: boolean
parameterProperty: boolean
destination: NormalizeValueDestination
}
| { kind: 'leave'; value: object }
/** Install one normalized node without `__proto__` assignment semantics. */
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
if (destination.kind === 'property') {
Object.defineProperty(destination.target, destination.key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
} else if (destination.kind === 'item') {
destination.target.items = value
} else {
destination.target[destination.index] = value
}
}
/** Install one normalized property map at its root or containing object. */
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
if (destination.kind === 'root') destination.holder.value = value
else destination.target.properties = value
}
/** Normalize one implicit property map and all descendants with explicit work frames. */
function normalizePropertyMap(
entries: Record<string, unknown>,
path: string,
requiredNames: ReadonlySet<string>,
raw: boolean,
): Record<string, unknown> {
const spec: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(entries)) {
Object.defineProperty(spec, key, {
value: normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true),
enumerable: true,
configurable: true,
writable: true,
})
}
return spec
}
/** Normalize one property or nested value schema into the host realm. */
function normalizeValueSchema(
value: unknown,
path: string,
forceRequired = false,
raw = false,
parameterProperty = false,
): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
const requiredKey = parameterProperty && !raw ? ['required'] : []
if (parameterProperty && raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (parameterProperty && !raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
if (forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
prop.oneOf = value.oneOf.map((branch, index) => normalizeValueSchema(branch, `${path}.oneOf[${index}]`, false, raw))
return prop
}
if (raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
return prop
}
if (!SCHEMA_TYPES.has(value.type) || raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
if (!isPlainRecord(value.properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = raw ? normalizeRequiredNames(value.required, value.properties, `${path}.required`) : new Set<string>()
prop.properties = normalizePropertyMap(value.properties, `${path}.properties`, nestedRequired, raw)
} else if (raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
return prop
const holder: NormalizeRoot = {}
const ancestors = new Set<object>()
const tasks: NormalizeTask[] = [{
kind: 'map',
entries,
path,
requiredNames,
raw,
destination: { kind: 'root', holder },
}]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.value)
continue
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw)
return prop
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
prop.enum = Array.isArray(value.enum)
? value.enum.map((entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
: value.enum
if (task.kind === 'map') {
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
ancestors.add(task.entries)
const spec: Record<string, unknown> = {}
assignNormalizedMap(task.destination, spec)
tasks.push({ kind: 'leave', value: task.entries })
const mapEntries = Object.entries(task.entries)
for (let index = mapEntries.length - 1; index >= 0; index--) {
const entry = mapEntries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'value',
value: entry[1],
path: `${task.path}.${entry[0]}`,
forceRequired: task.requiredNames.has(entry[0]),
raw: task.raw,
parameterProperty: true,
destination: { kind: 'property', target: spec, key: entry[0] },
})
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
return prop
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
return prop
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
continue
}
const { value, path } = task
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
ancestors.add(value)
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
assignNormalizedValue(task.destination, prop)
tasks.push({ kind: 'leave', value })
if (task.forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
const oneOf: Record<string, unknown>[] = []
prop.oneOf = oneOf
for (let index = value.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
value: value.oneOf[index],
path: `${path}.oneOf[${index}]`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'one-of', target: oneOf, index },
})
}
continue
}
if (task.raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
continue
}
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
const properties = value.properties
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = task.raw
? normalizeRequiredNames(value.required, properties, `${path}.required`)
: new Set<string>()
tasks.push({
kind: 'map',
entries: properties,
path: `${path}.properties`,
requiredNames: nestedRequired,
raw: task.raw,
destination: { kind: 'properties', target: prop },
})
} else if (task.raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
break
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) {
tasks.push({
kind: 'value',
value: value.items,
path: `${path}.items`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'item', target: prop },
})
}
break
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
prop.enum = Array.isArray(value.enum)
? Array.from(value.enum, (entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
: value.enum
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
break
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
break
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
}
}
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
return holder.value ?? {}
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
@@ -342,7 +520,7 @@ export function sandboxDefineTool(options: unknown): ToolDefinition {
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
}
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
const schema = normalizeValueSchema(output.schema, 'output.schema')
const schema = cloneJson(output.schema, 'output.schema')
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
const rawRender = output.render as (args: unknown, value: unknown) => unknown
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined

View File

@@ -321,6 +321,60 @@ describe('cordis_mount', () => {
})
})
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
const ctx = await setup()
const depth = 5_000
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'deep-unified-schema',
inject: ['tools'],
apply(ctx) {
let choice = { type: 'string' }
let example = 'leaf'
for (let index = 0; index < ${depth}; index++) {
choice = { oneOf: [choice, { type: 'null' }] }
example = [example]
}
harness.registerTool(ctx, harness.defineTool({
name: 'deep_unified_schema_tool',
description: 'deep unified nodes',
parameters: {
choice: { ...choice, required: true },
any: { type: 'json', default: example },
},
${CONTENT_OUTPUT_CODE}
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as {
properties: Record<string, Record<string, unknown>>
}
let choice = parameters.properties.choice!
let choiceDepth = 0
while (Array.isArray(choice.oneOf)) {
choice = choice.oneOf[0] as Record<string, unknown>
choiceDepth++
}
let example: unknown = parameters.properties.any!.default
let exampleDepth = 0
while (Array.isArray(example)) {
example = example[0]
exampleDepth++
}
expect({ choiceDepth, choice, exampleDepth, example }).toEqual({
choiceDepth: depth,
choice: { type: 'string' },
exampleDepth: depth,
example: 'leaf',
})
})
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
@@ -421,6 +475,47 @@ describe('cordis_mount', () => {
expect(text(result)).toContain(message)
})
it.each([
[
`
const parameters = {}
const item = { type: 'array' }
item.items = item
parameters.item = item
`,
'parameters.item.items is circular',
],
[
`
const parameters = {}
const item = { type: 'object', additionalProperties: true, properties: parameters }
parameters.item = item
`,
'parameters.item.properties is circular',
],
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'circular-schema',
inject: ['tools'],
apply(ctx) {
${declaration}
harness.registerTool(ctx, harness.defineTool({
name: 'circular_schema_tool',
description: 'circular',
parameters,
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain(message)
})
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {

View File

@@ -87,7 +87,7 @@ ctx.tools.register(defineTool({
}))
```
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so valid deep schemas are memory-bounded rather than call-stack-bounded.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
@@ -117,7 +117,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>` and a bounded Native-content summary; `deriveMessages()` does not surface that event or persist the value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders as pretty JSON, `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer logs, completion, or failure diagnostic; invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
### Parallel execution

View File

@@ -102,9 +102,94 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
}
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
const JSON_INDENT = ' '
/**
* ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
* renderer also caps TOTAL indentation there, compacting deeper subtrees, so
* formatted output remains linear in the canonical JSON size.
*/
const MAX_JSON_INDENT_CHARS = 10
/** A pending fragment in the iterative JSON presentation traversal. */
type JsonRenderTask =
| { kind: 'text'; text: string }
| { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
/** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
function renderJsonValue(value: Exclude<JsonValue, string>): string {
const chunks: string[] = []
const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'text') {
chunks.push(task.text)
continue
}
const current = task.value
if (current === null || typeof current === 'boolean' || typeof current === 'number') {
chunks.push(String(current))
continue
}
if (typeof current === 'string') {
chunks.push(JSON.stringify(current))
continue
}
const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
const childDepth = task.depth + 1
if (Array.isArray(current)) {
chunks.push('[')
if (current.length === 0) {
chunks.push(']')
continue
}
tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
for (let index = current.length - 1; index >= 0; index--) {
const item = current[index]
/* v8 ignore next -- canonical JsonValue arrays are dense. */
if (item === undefined) throw new Error('cannot render a sparse JSON array')
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
tasks.push({
kind: 'text',
text: compact
? index === 0 ? '' : ','
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
})
}
continue
}
const keys = Object.keys(current)
chunks.push('{')
if (keys.length === 0) {
chunks.push('}')
continue
}
tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
/* v8 ignore next -- the loop is bounded by the captured key count. */
if (key === undefined) throw new Error('cannot render a missing JSON object key')
const item = current[key]
/* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
if (item === undefined) throw new Error('cannot render an undefined JSON object property')
tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
tasks.push({
kind: 'text',
text: compact
? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
: `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
})
}
}
return chunks.join('')
}
/** Render one present program completion value for the model-facing result text. */
function renderValue(value: JsonValue): string {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return typeof value === 'string' ? value : renderJsonValue(value)
}
/** Canonical value returned by the outer Code Mode transport. */

View File

@@ -873,10 +873,14 @@ export class ToolRegistry extends Service {
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
const { name, description, parameters } = definition
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
if (detached === undefined) {
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
}
return {
name,
description,
parameters: detachParameters ? structuredClone(parameters) : parameters,
parameters: detached,
}
}

View File

@@ -116,18 +116,70 @@ function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is Jso
}
}
/** Collect every violation for one raw schema node. */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isPlainJsonRecord(node)) {
violations.push(`${path} must be a schema object`)
return
/** Deferred work for the stack-safe raw-schema walk. */
type SchemaWalkTask =
| { kind: 'enter'; node: unknown; path: string }
| { kind: 'leave'; node: object }
| { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
| { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
/** Keywords that are invalid beside `oneOf`. */
const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
/** Validate object-only fields after its property schemas have been visited. */
function checkObjectSchemaTail(
node: Record<string, unknown>,
path: string,
properties: unknown,
violations: string[],
): void {
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
return
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
seen.add(node)
try {
}
/** Collect every violation for one raw schema tree without using the JavaScript call stack. */
function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
seen.delete(task.node)
continue
}
if (task.kind === 'one-of-tail') {
for (const key of ONE_OF_SIBLING_KEYWORDS) {
if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
}
continue
}
if (task.kind === 'object-tail') {
checkObjectSchemaTail(task.node, task.path, task.properties, violations)
continue
}
const { node, path } = task
if (!isPlainJsonRecord(node)) {
violations.push(`${path} must be a schema object`)
continue
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
continue
}
seen.add(node)
tasks.push({ kind: 'leave', node })
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
@@ -151,28 +203,26 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
const hasOneOf = Object.hasOwn(node, 'oneOf')
if (hasType && hasOneOf) {
violations.push(`${path} cannot declare both type and oneOf`)
return
continue
}
if (!hasType && !hasOneOf) {
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
for (const key of ONE_OF_SIBLING_KEYWORDS) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
}
return
continue
}
if (hasOneOf) {
const oneOf = node.oneOf
tasks.push({ kind: 'one-of-tail', node, path })
if (!Array.isArray(oneOf) || oneOf.length < 2) {
violations.push(`${path}.oneOf must be an array of at least two schemas`)
} else {
for (let index = 0; index < oneOf.length; index++) {
checkSchemaNode(oneOf[index], `${path}.oneOf[${index}]`, violations, seen)
for (let index = oneOf.length - 1; index >= 0; index--) {
tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
}
}
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} is not supported beside oneOf`)
}
return
continue
}
const type = node.type
@@ -180,7 +230,7 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
return
continue
}
const schemaType = type as JsonSchemaType
const allowedFor: Record<string, JsonSchemaType[]> = {
@@ -200,33 +250,24 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
switch (schemaType) {
case 'object': {
const properties = node.properties
tasks.push({ kind: 'object-tail', node, path, properties })
if (Object.hasOwn(node, 'properties')) {
if (!isPlainJsonRecord(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
const entries = Object.entries(properties)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
}
}
}
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (Object.hasOwn(node, 'items')) checkSchemaNode(node.items, `${path}.items`, violations, seen)
if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
break
}
case 'string':
@@ -238,10 +279,8 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
const enumValid = Array.isArray(allowed)
&& allowed.length > 0
&& allowed.every(entry => scalarMatches(schemaType, entry))
if (Object.hasOwn(node, 'enum')) {
if (!enumValid) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
if (Object.hasOwn(node, 'enum') && !enumValid) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
const constValid = scalarMatches(schemaType, node.const)
if (Object.hasOwn(node, 'const')) {
@@ -256,8 +295,6 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
default: assertNever(schemaType, 'JsonSchemaType')
}
} finally {
seen.delete(node)
}
}
@@ -308,81 +345,57 @@ function propertyPath(path: string, key: string): string {
return path === '' ? key : `${path}.${key}`
}
/** Contain hostile getters/proxies so validation remains total for arbitrary values. */
function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.type !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(node.type)) {
return checkValueUnchecked(node, value, path)
}
try {
return checkValueUnchecked(node, value, path)
} catch {
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
/** One child evaluation deferred by a container or exact-one union frame. */
interface ValueChild {
readonly node: JsonSchemaNode
readonly value: unknown
readonly path: string
}
/** Explicit call frame for stack-safe schema-value validation. */
interface ValueFrame {
readonly node: JsonSchemaNode
readonly value: unknown
readonly path: string
catches: boolean
phase: 'start' | 'children'
kind?: 'oneOf' | 'object' | 'array'
children: ValueChild[]
childIndex: number
violations: string[]
tailViolations: string[]
matches: number
}
/** The generic exception-containment diagnostic owned by one valid schema node. */
function losslessValueViolation(path: string): string[] {
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
}
/** Append diagnostics without spreading a potentially wide child result as call arguments. */
function appendViolations(target: string[], source: readonly string[]): void {
for (const violation of source) target.push(violation)
}
/** Initialize one validation frame with empty aggregation state. */
function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
return {
node,
value,
path,
catches: false,
phase: 'start',
children: [],
childIndex: 0,
violations: [],
tailViolations: [],
matches: 0,
}
}
/** Collect value violations for one trusted schema node after the exception boundary. */
function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.oneOf !== undefined) {
const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length
return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`]
}
if (node.type === undefined) {
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON value`]
}
switch (node.type) {
case 'object': {
if (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], propertyPath(path, key)))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`)
}
}
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`]
}
case 'array': {
if (!Array.isArray(value)) return [`"${diagnosticPath(path)}" must be an array`]
const items = node.items
const violations = items === undefined
? []
: value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a dense lossless JSON array`]
}
case 'string': {
if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number') return [`"${diagnosticPath(path)}" must be a number`]
if (!isJsonNumber(value)) return [`"${diagnosticPath(path)}" must be a finite JSON number`]
break
}
case 'integer': {
if (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${diagnosticPath(path)}" must be null`]
break
}
default: return assertNever(node.type, 'JsonSchemaType')
}
if (node.enum !== undefined && !node.enum.includes(value)) {
/** Validate one scalar node after its primitive type check. */
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.enum !== undefined && !node.enum.includes(value as JsonSchemaScalar)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
}
if (Object.hasOwn(node, 'const') && value !== node.const) {
@@ -391,6 +404,165 @@ function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string)
return []
}
/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
const frames: ValueFrame[] = [valueFrame(schema, value, path)]
let rootResult: string[] | undefined
const receive = (result: string[]): void => {
const parent = frames.at(-1)
if (parent === undefined) {
rootResult = result
return
}
if (parent.kind === 'oneOf') {
if (result.length === 0) parent.matches++
} else {
appendViolations(parent.violations, result)
}
}
const finish = (result: string[]): void => {
frames.pop()
receive(result)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
try {
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing schema-value child frame')
frame.childIndex++
frames.push(valueFrame(child.node, child.value, child.path))
continue
}
if (frame.kind === 'oneOf') {
finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
continue
}
appendViolations(frame.violations, frame.tailViolations)
if (frame.violations.length > 0) {
finish(frame.violations)
} else if (frame.kind === 'object') {
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
} else {
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
}
continue
}
const nodeType = frame.node.type
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
const oneOf = frame.node.oneOf
if (oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
frame.childIndex = 0
frame.matches = 0
frame.phase = 'children'
continue
}
if (nodeType === undefined) {
finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
continue
}
switch (nodeType) {
case 'object': {
if (!isPlainJsonRecord(frame.value)) {
finish([`"${diagnosticPath(frame.path)}" must be an object`])
break
}
const properties = frame.node.properties ?? {}
const violations: string[] = []
for (const key of frame.node.required ?? []) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
}
}
const children: ValueChild[] = []
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
}
const tailViolations: string[] = []
if (frame.node.additionalProperties === false) {
for (const key of Object.keys(frame.value)) {
if (!Object.hasOwn(properties, key)) {
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
}
}
}
frame.kind = 'object'
frame.children = children
frame.childIndex = 0
frame.violations = violations
frame.tailViolations = tailViolations
frame.phase = 'children'
break
}
case 'array': {
if (!Array.isArray(frame.value)) {
finish([`"${diagnosticPath(frame.path)}" must be an array`])
break
}
const items = frame.node.items
const children = items === undefined
? []
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
frame.kind = 'array'
frame.children = children
frame.childIndex = 0
frame.violations = []
frame.phase = 'children'
break
}
case 'string':
finish(typeof frame.value === 'string'
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be a string`])
break
case 'number':
finish(typeof frame.value !== 'number'
? [`"${diagnosticPath(frame.path)}" must be a number`]
: !isJsonNumber(frame.value)
? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
: checkScalarValue(frame.node, frame.value, frame.path))
break
case 'integer':
finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
? [`"${diagnosticPath(frame.path)}" must be an integer`]
: checkScalarValue(frame.node, frame.value, frame.path))
break
case 'boolean':
finish(typeof frame.value === 'boolean'
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be a boolean`])
break
case 'null':
finish(frame.value === null
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be null`])
break
default:
finish(assertNever(nodeType, 'JsonSchemaType'))
}
} catch (error) {
let failed = frames.pop()
while (failed !== undefined && !failed.catches) failed = frames.pop()
if (failed === undefined) throw error
receive(losslessValueViolation(failed.path))
}
}
/* v8 ignore next -- every root frame finishes or throws. */
return rootResult ?? losslessValueViolation(path)
}
/**
* Validate a candidate value against an asserted raw schema. The function is
* total for arbitrary values and returns path-qualified violations.

View File

@@ -186,66 +186,172 @@ function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed
}
}
/** Compile one implicit property map, collecting per-property requiredness. */
function compilePropertyMap(
input: unknown,
path: string,
seen: Set<object>,
): { properties: Record<string, JsonSchemaNode>; required?: string[] } {
if (!isPlainJsonRecord(input)) authorError(`${path} must be an object of value schemas`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const properties: Record<string, JsonSchemaNode> = {}
const required: string[] = []
for (const [key, property] of Object.entries(input)) {
if (!isPlainJsonRecord(property)) authorError(`${path}.${key} must be a value schema object`)
if (Object.hasOwn(property, 'required') && property.required !== true) {
authorError(`${path}.${key}.required must be true when present`)
}
Object.defineProperty(properties, key, {
value: compileValueSchema(property, `${path}.${key}`, seen, true),
/** Compiled form of one implicit property map. */
interface CompiledPropertyMap {
properties: Record<string, JsonSchemaNode>
required?: string[]
}
/** Mutable holder used only while an iterative compilation root is unresolved. */
interface CompileRoot<T> {
value?: T
}
/** Where one compiled value node is installed. */
type NodeDestination =
| { kind: 'root'; holder: CompileRoot<JsonSchemaNode> }
| { kind: 'property'; target: Record<string, JsonSchemaNode>; key: string }
| { kind: 'item'; target: JsonSchemaNode }
| { kind: 'one-of'; target: JsonSchemaNode[]; index: number }
/** Where one compiled property map is installed. */
type PropertyMapDestination =
| { kind: 'root'; holder: CompileRoot<CompiledPropertyMap> }
| { kind: 'object'; target: JsonSchemaNode }
/** Deferred work for stack-safe author-schema compilation. */
type CompileTask =
| { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination }
| { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination }
| {
kind: 'property'
property: unknown
path: string
key: string
properties: Record<string, JsonSchemaNode>
required: string[]
}
| {
kind: 'property-map-tail'
compiled: CompiledPropertyMap
required: string[]
destination: PropertyMapDestination
}
| { kind: 'leave'; input: object }
/** Install a compiled node without giving `__proto__` assignment semantics. */
function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void {
switch (destination.kind) {
case 'root':
destination.holder.value = node
break
case 'property':
Object.defineProperty(destination.target, destination.key, {
value: node,
enumerable: true,
configurable: true,
writable: true,
})
if (property.required === true) required.push(key)
}
return required.length > 0 ? { properties, required } : { properties }
} finally {
seen.delete(input)
break
case 'item':
destination.target.items = node
break
case 'one-of':
destination.target[destination.index] = node
break
}
}
/** Compile one author node without applying any consumer root restriction. */
function compileValueSchema(
input: unknown,
path: string,
seen: Set<object>,
allowRequired = false,
): JsonSchemaNode {
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const authorKeys = [...ANNOTATION_KEYS, ...(allowRequired ? ['required'] : [])]
/** Install a compiled property map at its root or containing object node. */
function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void {
if (destination.kind === 'root') {
destination.holder.value = compiled
} else {
destination.target.properties = compiled.properties
}
}
/** Execute an author-schema compilation task graph without recursive descent. */
function runSchemaCompiler(initial: CompileTask): void {
const seen = new Set<object>()
const tasks: CompileTask[] = [initial]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
seen.delete(task.input)
continue
}
if (task.kind === 'property-map-tail') {
if (task.required.length > 0) {
task.compiled.required = task.required
if (task.destination.kind === 'object') task.destination.target.required = task.required
}
continue
}
if (task.kind === 'property') {
if (!isPlainJsonRecord(task.property)) authorError(`${task.path} must be a value schema object`)
if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
authorError(`${task.path}.required must be true when present`)
}
if (task.property.required === true) task.required.push(task.key)
tasks.push({
kind: 'value',
input: task.property,
path: task.path,
allowRequired: true,
destination: { kind: 'property', target: task.properties, key: task.key },
})
continue
}
if (task.kind === 'property-map') {
if (!isPlainJsonRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
if (seen.has(task.input)) authorError(`${task.path} is circular`)
seen.add(task.input)
const compiled: CompiledPropertyMap = { properties: {} }
const required: string[] = []
assignCompiledPropertyMap(task.destination, compiled)
tasks.push({ kind: 'leave', input: task.input })
tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination })
const entries = Object.entries(task.input)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'property',
property: entry[1],
path: `${task.path}.${entry[0]}`,
key: entry[0],
properties: compiled.properties,
required,
})
}
continue
}
const { input, path } = task
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
const node: JsonSchemaNode = {}
assignCompiledNode(task.destination, node)
tasks.push({ kind: 'leave', input })
if (Object.hasOwn(input, 'oneOf')) {
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
if (!Array.isArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
node.oneOf = input.oneOf.map((branch, index) => compileValueSchema(branch, `${path}.oneOf[${index}]`, seen))
const branches: JsonSchemaNode[] = []
node.oneOf = branches
copyAnnotations(input, node)
return node
for (let index = input.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
input: input.oneOf[index],
path: `${path}.oneOf[${index}]`,
allowRequired: false,
destination: { kind: 'one-of', target: branches, index },
})
}
continue
}
switch (input.type) {
case 'json':
assertAuthorKeys(input, path, [...authorKeys, 'type'])
copyAnnotations(input, node)
return node
case 'object': {
break
case 'object':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties'])
if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') {
authorError(`${path}.additionalProperties must be explicitly true or false`)
@@ -254,18 +360,28 @@ function compileValueSchema(
copyAnnotations(input, node)
node.additionalProperties = input.additionalProperties
if (Object.hasOwn(input, 'properties')) {
const compiled = compilePropertyMap(input.properties, `${path}.properties`, seen)
node.properties = compiled.properties
if (compiled.required !== undefined) node.required = compiled.required
tasks.push({
kind: 'property-map',
input: input.properties,
path: `${path}.properties`,
destination: { kind: 'object', target: node },
})
}
return node
}
break
case 'array':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
node.type = 'array'
copyAnnotations(input, node)
if (Object.hasOwn(input, 'items')) node.items = compileValueSchema(input.items, `${path}.items`, seen)
return node
if (Object.hasOwn(input, 'items')) {
tasks.push({
kind: 'value',
input: input.items,
path: `${path}.items`,
allowRequired: false,
destination: { kind: 'item', target: node },
})
}
break
case 'string':
case 'number':
case 'integer':
@@ -280,15 +396,29 @@ function compileValueSchema(
: input.enum as JsonSchemaScalar[]
}
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
return node
break
default:
return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
}
} finally {
seen.delete(input)
}
}
/** Compile one implicit property map, collecting per-property requiredness. */
function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap {
const holder: CompileRoot<CompiledPropertyMap> = {}
runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } })
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
return holder.value ?? authorError(`${path} did not compile`)
}
/** Compile one author node without applying any consumer root restriction. */
function compileValueSchema(input: unknown, path: string): JsonSchemaNode {
const holder: CompileRoot<JsonSchemaNode> = {}
runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } })
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
return holder.value ?? authorError(`${path} did not compile`)
}
/**
* Compile one author-facing value schema to the enforced raw JSON Schema
* subset. The author-only `json` node becomes an annotation-only schema.
@@ -296,7 +426,7 @@ function compileValueSchema(
* @returns The asserted raw schema projection.
*/
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
const schema = compileValueSchema(spec, 'schema', new Set())
const schema = compileValueSchema(spec, 'schema')
assertSupportedJsonSchema(schema)
return schema
}
@@ -307,7 +437,7 @@ export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNo
* @returns An object-rooted raw schema with no implicit-root openness override.
*/
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
const compiled = compilePropertyMap(spec, 'parameters', new Set())
const compiled = compilePropertyMap(spec, 'parameters')
const schema: ParameterJsonSchema = {
type: 'object',
properties: compiled.properties,

View File

@@ -9,7 +9,6 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */
export interface ToolSdkSchema extends ToolSchema {
/** Validated canonical value returned by the tool binding. */
@@ -53,9 +52,181 @@ function renderConstrainedScalar(node: Record<string, unknown>, type: string): s
return broad
}
/** Parenthesize a union or object intersection before applying `[]`. */
function arrayItem(type: string): string {
return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]`
/** A composable type document that can be flattened without recursive string concatenation. */
interface TypeDocument {
readonly parts: readonly (string | TypeDocument)[]
readonly containsUnionOrIntersection: boolean
}
/** Build one document from captured parts while retaining the legacy array-parenthesization test. */
function typeDocumentFrom(parts: readonly (string | TypeDocument)[]): TypeDocument {
return {
parts,
containsUnionOrIntersection: parts.some(part => typeof part === 'string'
? part.includes('|') || part.includes('&')
: part.containsUnionOrIntersection),
}
}
/** Build a small document without an intermediate array at each call site. */
function typeDocument(...parts: (string | TypeDocument)[]): TypeDocument {
return typeDocumentFrom(parts)
}
/** Flatten a nested document with an explicit work stack. */
function flattenTypeDocument(document: TypeDocument): string {
const chunks: string[] = []
const tasks: (string | TypeDocument)[] = [document]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (typeof task === 'string') {
chunks.push(task)
continue
}
for (let index = task.parts.length - 1; index >= 0; index--) {
const part = task.parts[index]
/* v8 ignore next -- the loop is bounded by the captured part count. */
if (part !== undefined) tasks.push(part)
}
}
return chunks.join('')
}
/** One explicit call frame for stack-safe schema-to-TypeScript rendering. */
interface SchemaRenderFrame {
readonly node: JsonSchemaNode
readonly indent: number
phase: 'start' | 'children'
kind?: 'oneOf' | 'array' | 'object'
children: { node: JsonSchemaNode; indent: number }[]
childIndex: number
childDocuments: TypeDocument[]
entries: [string, JsonSchemaNode][]
}
/** Initialize one schema-render frame with empty aggregation state. */
function schemaRenderFrame(node: JsonSchemaNode, indent: number): SchemaRenderFrame {
return { node, indent, phase: 'start', children: [], childIndex: 0, childDocuments: [], entries: [] }
}
/** Render an already asserted schema to a composable document. */
function renderSupportedSchema(schema: JsonSchemaNode, indent: number): TypeDocument {
const frames: SchemaRenderFrame[] = [schemaRenderFrame(schema, indent)]
let rootDocument: TypeDocument | undefined
const finish = (document: TypeDocument): void => {
frames.pop()
const parent = frames.at(-1)
if (parent === undefined) rootDocument = document
else parent.childDocuments.push(document)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing schema render child')
frame.childIndex++
frames.push(schemaRenderFrame(child.node, child.indent))
continue
}
if (frame.kind === 'oneOf') {
const parts: (string | TypeDocument)[] = []
for (let index = 0; index < frame.childDocuments.length; index++) {
if (index > 0) parts.push(' | ')
const child = frame.childDocuments[index]
/* v8 ignore next -- child documents correspond one-to-one with children. */
if (child !== undefined) parts.push(child)
}
finish(typeDocumentFrom(parts))
continue
}
if (frame.kind === 'array') {
const child = frame.childDocuments[0]
/* v8 ignore next -- array frames always schedule exactly one child. */
if (child === undefined) throw new Error('missing array item type')
finish(child.containsUnionOrIntersection
? typeDocument('(', child, ')[]')
: typeDocument(child, '[]'))
continue
}
const required = new Set(frame.node.required)
const parts: (string | TypeDocument)[] = ['{']
for (let index = 0; index < frame.entries.length; index++) {
const entry = frame.entries[index]
const child = frame.childDocuments[index]
/* v8 ignore next -- object entries and child documents have the same length. */
if (entry === undefined || child === undefined) throw new Error('missing object property type')
const [name, prop] = entry
for (const line of docLines(prop.description, frame.indent + 1)) parts.push('\n', line)
parts.push('\n', `${pad(frame.indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: `, child, ';')
}
parts.push('\n', `${pad(frame.indent)}}`)
const declared = typeDocumentFrom(parts)
finish(frame.node.additionalProperties === false
? declared
: typeDocument(declared, ' & Record<string, JsonValue>'))
continue
}
const node = frame.node
if (node.oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(node.oneOf, child => ({ node: child, indent: frame.indent }))
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
continue
}
if (node.type === undefined) {
finish(typeDocument('JsonValue'))
continue
}
switch (node.type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
finish(typeDocument(renderConstrainedScalar(node as Record<string, unknown>, node.type)))
break
case 'array':
if (node.items === undefined) {
finish(typeDocument('JsonValue[]'))
} else {
frame.kind = 'array'
frame.children = [{ node: node.items, indent: frame.indent }]
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
}
break
case 'object': {
const open = node.additionalProperties !== false
const entries = Object.entries(node.properties ?? {})
if (entries.length === 0) {
finish(typeDocument(open ? 'Record<string, JsonValue>' : 'Record<string, never>'))
} else {
frame.kind = 'object'
frame.entries = entries
frame.children = entries.map(([, child]) => ({ node: child, indent: frame.indent + 1 }))
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
}
break
}
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
default:
finish(typeDocument('unknown'))
}
}
/* v8 ignore next -- every root frame produces one document. */
return rootDocument ?? typeDocument('unknown')
}
/**
@@ -69,43 +240,10 @@ function arrayItem(type: string): string {
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
try {
assertSupportedJsonSchema(schema)
return flattenTypeDocument(renderSupportedSchema(schema, indent))
} catch {
return 'unknown'
}
const node = schema as Record<string, unknown>
if (Object.hasOwn(node, 'oneOf')) {
return (node.oneOf as unknown[]).map(branch => jsonSchemaToTs(branch, indent)).join(' | ')
}
if (!Object.hasOwn(node, 'type')) return 'JsonValue'
switch (node.type) {
case 'string': return renderConstrainedScalar(node, 'string')
case 'number': return renderConstrainedScalar(node, 'number')
case 'integer': return renderConstrainedScalar(node, 'integer')
case 'boolean': return renderConstrainedScalar(node, 'boolean')
case 'null': return renderConstrainedScalar(node, 'null')
case 'array': {
return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue')
}
case 'object': {
const properties = node.properties
const open = node.additionalProperties !== false
if (properties === undefined) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const entries = Object.entries(properties as Record<string, unknown>)
if (entries.length === 0) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const required = new Set(node.required as string[] | undefined)
const lines: string[] = ['{']
for (const [name, prop] of entries) {
const description = (prop as Record<string, unknown>).description
lines.push(...docLines(description, indent + 1))
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
}
lines.push(`${pad(indent)}}`)
const declared = lines.join('\n')
return open ? `${declared} & Record<string, JsonValue>` : declared
}
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
default: return 'unknown'
}
}
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */

View File

@@ -10,7 +10,7 @@ import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DI
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
@@ -884,10 +884,17 @@ describe('the run_code dispatch bridge', () => {
it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42 } })
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42\n}' })
runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } })
expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' })
runtime.behavior = () => Promise.resolve({ logs: [], value: {} })
expect((await runCode(ctx, 'empty object')).content[0]).toEqual({ type: 'text', text: '{}' })
const nested = { outer: [{ inner: true }] }
runtime.behavior = () => Promise.resolve({ logs: [], value: nested })
expect((await runCode(ctx, 'nested')).content[0]).toEqual({ type: 'text', text: JSON.stringify(nested, null, 2) })
runtime.behavior = () => Promise.resolve({ logs: [], value: ['x', 2] })
expect((await runCode(ctx, 'array')).content[0]).toEqual({ type: 'text', text: '[\n "x",\n 2\n]' })
runtime.behavior = () => Promise.resolve({ logs: [], value: [] })
expect((await runCode(ctx, 'empty array')).content[0]).toEqual({ type: 'text', text: '[]' })
runtime.behavior = () => Promise.resolve({ logs: [], value: null })
expect((await runCode(ctx, 'null')).content[0]).toEqual({ type: 'text', text: 'null' })
runtime.behavior = () => Promise.resolve({ logs: [], value: 'raw' })
@@ -898,6 +905,27 @@ describe('the run_code dispatch bridge', () => {
expect(absent.isError ? undefined : absent.value).toEqual({ logs: [] })
})
it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
let value: JsonValue = {
emptyArray: [],
emptyObject: {},
pair: ['leaf', 2],
record: { first: true, second: null },
}
for (let depth = 0; depth < 5_000; depth++) value = [value]
runtime.behavior = () => Promise.resolve({ logs: [], value })
const result = await runCode(ctx, 'deep result')
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: 'text'; text: string }).text
expect(text.startsWith('[\n [\n [')).toBe(true)
expect(text).toContain('"leaf"')
expect(text.endsWith(']')).toBe(true)
expect(text.length).toBeLessThan(11_000)
})
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -214,6 +214,14 @@ describe('the enforced raw JSON Schema subset', () => {
.toEqual(['schema.properties.at must be a schema object'])
})
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
})
it('uses own-property semantics for required declarations', () => {
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
@@ -322,6 +330,17 @@ describe('validateJsonSchemaValue', () => {
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
})
it('validates deeply nested exact-one unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
assertSupportedJsonSchema(schema)
expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([])
expect(validateJsonSchemaValue(schema, 42))
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
})
it('an unconstrained schema accepts only lossless JSON values', () => {
const anyJson = asserted({})
for (const value of [null, true, 1, 'x', [1], { x: null }]) {

View File

@@ -92,6 +92,23 @@ describe('the unified author schema DSL', () => {
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
})
it('compiles deeply nested author unions without using the JavaScript call stack', () => {
const depth = 5_000
let spec: unknown = { type: 'string' }
for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
let cursor = compiled
let layers = 0
while (cursor.oneOf !== undefined) {
cursor = cursor.oneOf[0]!
layers++
}
expect(layers).toBe(depth)
expect(cursor).toEqual({ type: 'string' })
})
it('preserves a property literally named __proto__ as schema data', () => {
const properties = Object.create(null) as ParameterSchemaSpec
properties.__proto__ = { type: 'string', required: true }

View File

@@ -8,7 +8,7 @@ import ToolRegistry, {
defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken,
type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
@@ -1687,6 +1687,41 @@ describe('ToolRegistry', () => {
}])
})
it('schemas() snapshots deeply nested parameters without using structured-clone recursion', async () => {
const ctx = await setup()
const depth = 5_000
let nested: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) nested = { oneOf: [nested, { type: 'null' }] }
ctx.tools.register({
...echoTool,
name: 'deep-schema',
parameters: { type: 'object', properties: { nested } },
})
const projected = ctx.tools.schemas()[0]!.parameters as JsonSchemaNode
let cursor = projected.properties!.nested!
let layers = 0
while (cursor.oneOf !== undefined) {
cursor = cursor.oneOf[0]!
layers++
}
expect(layers).toBe(depth)
expect(cursor).toEqual({ type: 'string' })
})
it('rejects schema projection when a raw registration is not lossless JSON', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'lossy-schema',
parameters: { type: 'object', default: Number.NaN },
})
expect(() => ctx.tools.schemas())
.toThrow('tool "lossy-schema" parameters must be lossless JSON before schema projection')
})
it('rejects a non-positive or non-finite registration timeout', async () => {
const ctx = await setup()
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))

View File

@@ -93,6 +93,17 @@ describe('jsonSchemaToTs', () => {
expect(rendered).not.toContain('tool-*/ over')
expect(rendered).toContain(String.raw`tool-*\/ over`)
})
it('renders deeply nested unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: unknown = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
const rendered = jsonSchemaToTs(schema)
expect(rendered.startsWith('string | null')).toBe(true)
expect(rendered.length).toBe('string'.length + depth * ' | null'.length)
})
})
describe('renderToolsSdk', () => {

View File

@@ -55,13 +55,18 @@
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -70,11 +75,13 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {

View File

@@ -0,0 +1,243 @@
import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import type { ToolResult } from '@deepseek-ai/dsh-tools'
import { launcherPath } from 'node-addon-landlock-run'
import * as agentSpine from '../src/index.ts'
const bwrapUsable = spawnSync('bwrap', [
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true',
], { timeout: 5_000, stdio: 'ignore' }).status === 0
const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const seatbeltUsable = process.platform === 'darwin'
&& spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable
let ctx: Context | undefined
let projectA: string
let projectB: string
const tempDirs: string[] = []
async function projectDir(label: string): Promise<string> {
const dir = await mkdtemp(join(homedir(), `dsh-${label}-`))
tempDirs.push(dir)
return dir
}
async function expectMissing(path: string): Promise<void> {
await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
}
function resultText(result: ToolResult): string {
return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
}
beforeEach(async () => {
projectA = await projectDir('project-a')
projectB = await projectDir('project-b')
const fallbackRoot = await projectDir('fallback')
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
await ctx.plugin(agentSpine, {
workspaceContext: false,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false,
})
await new Promise(resolve => setTimeout(resolve, 50))
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
})
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
async function agents() {
const active = ctx as Context
const [a, b] = await Promise.all([
active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }),
active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }),
])
return { active, agentA: a.agent, agentB: b.agent }
}
describe('one-context multi-project sandbox', () => {
it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => {
const { active, agentA, agentB } = await agents()
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
active.tools.execute({
callId: CallId('bash-a-own'), name: 'bash', agent: agentA,
signal: new AbortController().signal,
arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' },
}),
active.tools.execute({
callId: CallId('bash-b-own'), name: 'bash', agent: agentB,
signal: new AbortController().signal,
arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' },
}),
active.tools.execute({
callId: CallId('bash-a-cross'), name: 'bash', agent: agentA,
signal: new AbortController().signal,
arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' },
}),
active.tools.execute({
callId: CallId('bash-b-cross'), name: 'bash', agent: agentB,
signal: new AbortController().signal,
arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' },
}),
])
expect(aOwn.isError).toBe(false)
expect(bOwn.isError).toBe(false)
expect(aCross.isError).toBe(false)
expect(bCross.isError).toBe(false)
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
await expectMissing(join(projectB, 'from-a.txt'))
await expectMissing(join(projectA, 'from-b.txt'))
})
it('confines concurrent filesystem writes to each calling session workspace', async () => {
const { active, agentA, agentB } = await agents()
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
active.tools.execute({
callId: CallId('fs-a-own'), name: 'write', agent: agentA,
signal: new AbortController().signal,
arguments: { file_path: 'a-owned.txt', content: 'a' },
}),
active.tools.execute({
callId: CallId('fs-b-own'), name: 'write', agent: agentB,
signal: new AbortController().signal,
arguments: { file_path: 'b-owned.txt', content: 'b' },
}),
active.tools.execute({
callId: CallId('fs-a-cross'), name: 'write', agent: agentA,
signal: new AbortController().signal,
arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' },
}),
active.tools.execute({
callId: CallId('fs-b-cross'), name: 'write', agent: agentB,
signal: new AbortController().signal,
arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' },
}),
])
expect(aOwn.isError).toBe(false)
expect(bOwn.isError).toBe(false)
expect(aCross.isError).toBe(true)
expect(bCross.isError).toBe(true)
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
await expectMissing(join(projectB, 'from-a.txt'))
await expectMissing(join(projectA, 'from-b.txt'))
})
it.skipIf(!processSandboxUsable)('keeps symlink-sensitive session cwd semantics aligned across bash, fs, and policy', async () => {
const active = ctx as Context
const lexicalRoot = await projectDir('lexical-workspace')
const physicalRoot = await projectDir('physical-workspace')
const physicalChild = join(physicalRoot, 'child')
await mkdir(physicalChild)
const link = join(lexicalRoot, 'link')
await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
const sessionCwd = `${link}/..`
const handle = await active.agents.create({
sessionId: SessionId('symlink-parent-session'),
meta: { cwd: sessionCwd },
})
const [bashOwn, bashLexical, fsOwn, fsLexical] = await Promise.all([
active.tools.execute({
callId: CallId('bash-symlink-own'), name: 'bash', agent: handle.agent,
signal: new AbortController().signal,
arguments: { command: 'printf bash > bash-owned.txt', description: 'Write physical workspace marker' },
}),
active.tools.execute({
callId: CallId('bash-symlink-lexical'), name: 'bash', agent: handle.agent,
signal: new AbortController().signal,
arguments: { command: `printf escaped > ${join(lexicalRoot, 'bash-escaped.txt')}`, description: 'Attempt lexical workspace write' },
}),
active.tools.execute({
callId: CallId('fs-symlink-own'), name: 'write', agent: handle.agent,
signal: new AbortController().signal,
arguments: { file_path: 'fs-owned.txt', content: 'fs' },
}),
active.tools.execute({
callId: CallId('fs-symlink-lexical'), name: 'write', agent: handle.agent,
signal: new AbortController().signal,
arguments: { file_path: join(lexicalRoot, 'fs-escaped.txt'), content: 'escaped' },
}),
])
expect(bashOwn.isError).toBe(false)
expect(resultText(bashOwn)).not.toContain('[sandbox:')
expect(bashLexical.isError).toBe(false)
expect(resultText(bashLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(fsOwn.isError).toBe(false)
expect(fsLexical.isError).toBe(true)
expect(resultText(fsLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
expect(await readFile(join(physicalRoot, 'bash-owned.txt'), 'utf8')).toBe('bash')
expect(await readFile(join(physicalRoot, 'fs-owned.txt'), 'utf8')).toBe('fs')
await expectMissing(join(lexicalRoot, 'bash-escaped.txt'))
await expectMissing(join(lexicalRoot, 'fs-escaped.txt'))
})
it.skipIf(!processSandboxUsable)('resolves parent traversal from a symlinked session root consistently', async () => {
const active = ctx as Context
const lexicalRoot = await projectDir('lexical-parent')
const physicalRoot = await projectDir('physical-parent')
const physicalChild = join(physicalRoot, 'child')
await mkdir(physicalChild)
const link = join(lexicalRoot, 'link')
await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
await writeFile(join(lexicalRoot, 'shared.txt'), 'from-lexical-parent')
await writeFile(join(physicalRoot, 'shared.txt'), 'from-physical-parent')
const handle = await active.agents.create({
sessionId: SessionId('symlink-root-parent-path-session'),
meta: { cwd: link },
})
const [bashRead, fsRead] = await Promise.all([
active.tools.execute({
callId: CallId('bash-symlink-parent-read'), name: 'bash', agent: handle.agent,
signal: new AbortController().signal,
arguments: { command: 'cat ../shared.txt', description: 'Read through the physical parent' },
}),
active.tools.execute({
callId: CallId('fs-symlink-parent-read'), name: 'read', agent: handle.agent,
signal: new AbortController().signal,
arguments: { file_path: '../shared.txt' },
}),
])
expect(bashRead.isError).toBe(false)
expect(fsRead.isError).toBe(false)
expect(resultText(bashRead)).toContain('from-physical-parent')
expect(resultText(fsRead)).toContain('from-physical-parent')
expect(resultText(bashRead)).not.toContain('from-lexical-parent')
expect(resultText(fsRead)).not.toContain('from-lexical-parent')
})
})

View File

@@ -14,7 +14,7 @@ import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const CLI_NAME = 'dsh-cli-demo'
const DEFAULT_CONFIG_PATH = './cordis.yml'
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] <task>\n`
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p <task> | <task>)\n`
/** Supported CLI output encodings. */
export type OutputFormat = typeof OUTPUT_FORMATS[number]
@@ -73,6 +73,7 @@ interface ParsedArguments {
readonly config?: string
readonly 'output-format'?: string
readonly help?: boolean
readonly prompt?: string
}
readonly positionals: string[]
}
@@ -129,6 +130,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
config: { type: 'string' },
'output-format': { type: 'string' },
help: { type: 'boolean' },
prompt: { type: 'string', short: 'p' },
},
allowPositionals: true,
strict: true,
@@ -138,12 +140,16 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
}
if (parsed.values.help === true) return { kind: 'help' }
if (parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`)
const prompt = parsed.values.prompt
if (prompt !== undefined && parsed.positionals.length > 0) {
throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive')
}
// Cardinality was checked above, so index zero exists.
if (prompt === undefined && parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
}
// Cardinality was checked above, so the fallback index zero exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const task = parsed.positionals[0]!
const task = prompt ?? parsed.positionals[0]!
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
const requestedFormat = parsed.values['output-format'] ?? 'text'

View File

@@ -8,6 +8,15 @@ import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines
* floor — strips types natively, so plain `node` loads it), its config carries a `disabled:
* true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally
* fiber-less entry for a failed import), and the optional spill pair loads from the consumer
* install — so every passing boot proves all three alongside the CLI's own output contract.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
const decompress = promisify(zstdDecompress)
@@ -17,6 +26,7 @@ const dshPackages = [
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
'context/workspace-context',
'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
@@ -35,15 +45,18 @@ async function makeConsumer(): Promise<string> {
const nodeModules = join(dir, 'node_modules')
for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
await writeFile(join(dir, 'mock-llm.mjs'), [
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
await writeFile(join(dir, 'mock-llm.ts'), [
// Real type annotations: this file exists to prove plain Node's type
// stripping loads an example-local TS plugin from a built consumer.
"import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'",
"import type { Context } from 'cordis'",
'class Mock extends LlmAdapter {',
' async * stream(options) {',
" const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
' async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {',
" const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
" yield { type: 'block-start', index: 0, blockType: 'text' }",
" if (text === 'hang') {",
" yield { type: 'text-delta', index: 0, text: 'partial' }",
' await new Promise((resolve, reject) => {',
' await new Promise<never>((resolve, reject) => {',
" const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
" const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
' if (options.signal.aborted) onAbort()',
@@ -60,12 +73,12 @@ async function makeConsumer(): Promise<string> {
'}',
"export const name = 'built-cli-mock'",
"export const inject = ['llm']",
"export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
"export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
'',
].join('\n'))
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.mjs'",
" name: './mock-llm.ts'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',
@@ -76,6 +89,18 @@ async function makeConsumer(): Promise<string> {
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
' workspaceContext: false',
'- id: spill-local',
" name: '@deepseek-ai/dsh-spill-local'",
'- id: spill-policy',
" name: '@deepseek-ai/dsh-spill-policy'",
' config:',
' maxInlineBytes: 50000',
// A `disabled: true` entry settles without a fiber by design; the fail-loud
// entry-load guard must not mistake it for a failed import. The nonexistent
// path makes that distinction observable while a clean run proves boot continued.
'- id: off',
" name: './does-not-exist.ts'",
' disabled: true',
'',
].join('\n'))
return dir

View File

@@ -166,15 +166,19 @@ describe('parseCliArgs', () => {
kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
})
expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' })
expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' })
expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
})
it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
expect(() => parseCliArgs([])).toThrow('received 0')
expect(() => parseCliArgs([' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive')
expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option')
expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option')
})
})

View File

@@ -11,8 +11,16 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/
const NAME = 'dsh-tui-demo'
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
built-bin smokes */
dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
the built-bin fail-loud smoke */
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is
// logged per-entry rather than rethrown, so a piped launch would otherwise
// settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; `
+ 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
process.exit(1)
}
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))

View File

@@ -27,7 +27,6 @@ import * as uiTui from '@deepseek-ai/dsh-tui'
export const name = 'tui-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
const DEFAULT_WELCOME = 'ready.'
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
export interface Config {
@@ -51,8 +50,15 @@ export interface Config {
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** TUI subtitle rendered on start. Defaults to `ready.`. */
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes via this app's env var, e.g.
* `RESUME_SESSION_ID={session} dsh`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
@@ -84,7 +90,8 @@ export const Config: z<Config> = z.object({
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
welcome: z.string().default(DEFAULT_WELCOME),
welcome: z.string(),
resumeCommand: z.string(),
ui: uiTui.TuiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
@@ -115,7 +122,8 @@ export function composeTuiApp(ctx: Context, config: Config): void {
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui, {
...config.ui,
welcome: config.welcome ?? DEFAULT_WELCOME,
...config.welcome === undefined ? {} : { welcome: config.welcome },
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
sessionId,
})
ctx.plugin(agentCore, {

View File

@@ -0,0 +1,98 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
* one sanctioned PTY surface).
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
// Symlink each package the bin imports at module load by package name so plain
// Node resolves its built `main`, matching an installed dependency rather than
// tsconfig paths.
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
return json.name
}
/** Build a temporary external consumer with built workspace/vendor links. */
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
const abs = join(repoRoot, 'packages', rel)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
return dir
}
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// NO tsx — this is the published `node lib/bin.js` path (`--expose-internals`
// matches the demo command; the guard fires before the Loader needs it).
const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
let consumer: string | undefined
afterEach(async () => {
// Windows can briefly retain released handles after exit; retry removal.
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
consumer = await makeConsumer()
const { stdout, code, stderr } = await runBuiltBin(consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh-cli-demo')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -33,6 +33,7 @@ describe('dsh-tui-demo app', () => {
persistenceRoot: '/tmp/tui-sessions',
persistenceCompression: 'none',
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
ui: { color: false, maxToolOutputLines: 3 },
skills: { tool: { catalogDescriptionMaxLength: 8 } },
toolBash: { enableRunInBackground: false },
@@ -52,7 +53,12 @@ describe('dsh-tui-demo app', () => {
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig).toMatchObject({
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
color: false,
maxToolOutputLines: 3,
})
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[5]?.config as {
readonly agents: Array<Record<string, unknown>>
@@ -88,7 +94,8 @@ describe('dsh-tui-demo app', () => {
})
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
expect(calls[4]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',

View File

@@ -6,9 +6,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|---|---|---|
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).

View File

@@ -2,11 +2,11 @@
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots.
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots.
## The fence
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
The per-call policy carries the effective mode (session override or escalation grant) together with the calling session's immutable cwd root, falling back to deployment policy only for calls without one:
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
@@ -30,4 +30,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s.
- **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift.
- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed.
- **Requires `ctx.sandboxPolicy`** — tools use it to resolve each session policy and the backend uses it for agentless-call fallbacks; the backend does not confine without it composed.

View File

@@ -3,7 +3,7 @@
* `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
* write and the read-match-write edit critical section — are the local
* implementation's, verbatim; this package adds only the per-call MODE fence
* implementation's, verbatim; this package adds only the per-call POLICY fence
* on the two mutations. Reads pass through untouched: every mode permits
* reading.
*
@@ -17,9 +17,9 @@
* syscall) is narrowed by re-canonicalizing immediately before delegating and
* is accepted for this threat model.
*
* Per-call mode: `read-only` denies every mutation; `workspace-write` allows a
* mutation only when the target canonicalizes under the workspace root or a
* platform temp area (the SAME writable-root set the Seatbelt profile grants,
* Per-call policy: `read-only` denies every mutation; `workspace-write` allows
* a mutation only when the target canonicalizes under the policy's workspace
* root or a platform temp area (the SAME writable-root set Seatbelt grants,
* derived from the one `writableRoots` function so bash and fs cannot drift);
* `danger-full-access` delegates unfenced. A denial throws the structured
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
@@ -36,15 +36,15 @@ import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { isPathUnder } from './containment.ts'
/**
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
* base for relative paths). The sandbox default (mode + `workspace-write`
* boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home
* both enforcing families share.
* fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
* session for both enforcing families.
*/
export type Config = LocalConfig
@@ -52,26 +52,17 @@ export type Config = LocalConfig
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
* swap — the model-facing tools are untouched). Its configured default mode is
* the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's
* `sandbox/mode` override and stamps the effective mode onto each mutation,
* while an approved escalation may stamp a strictly wider mode for one call.
* the capability fact exposed by {@link sandboxMode}; `dsh-tool-fs` resolves
* each session's mode and cwd into a policy for every mutation, while an
* approved escalation may stamp a strictly wider mode for one call.
*/
export class SandboxedFileSystem extends LocalFileSystem {
static inject = ['sandboxPolicy']
private readonly defaultMode: SandboxMode
/**
* The canonical roots a `workspace-write` mutation may land under, computed
* once (the workspace root and platform temp areas are fixed for the
* provider's lifetime): the same set {@link writableRoots} gives every
* enforcement dialect, so the fs fence and the bash runner agree.
*/
private readonly writableRoots: string[]
constructor(ctx: Context, config: Config) {
super(ctx, config)
this.defaultMode = ctx.sandboxPolicy.defaultMode
this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot })
}
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
@@ -80,13 +71,14 @@ export class SandboxedFileSystem extends LocalFileSystem {
}
/**
* Fence the write by the per-call mode, then delegate to the inherited
* Fence the write by the per-call policy, then delegate to the inherited
* atomic write. See {@link checkedTarget}.
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the write outcome from the inherited backend.
*/
override async writeText(
@@ -94,19 +86,20 @@ export class SandboxedFileSystem extends LocalFileSystem {
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome> {
return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal)
return super.writeText(await this.checkedTarget(target, sandboxPolicy), content, expected, signal)
}
/**
* Fence the edit by the per-call mode, then delegate to the inherited
* Fence the edit by the per-call policy, then delegate to the inherited
* atomic edit. See {@link checkedTarget}.
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call mode; omit to use the deployment default.
* @param sandboxPolicy - the per-call mode and workspace root; omit to use
* the deployment fallback.
* @returns the edit outcome from the inherited backend.
*/
override async editText(
@@ -114,13 +107,13 @@ export class SandboxedFileSystem extends LocalFileSystem {
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome> {
return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal)
return super.editText(await this.checkedTarget(target, sandboxPolicy), edit, expected, signal)
}
/**
* Enforce the per-call mode against `target` and return the EXACT target the
* Enforce the per-call policy against `target` and return the EXACT target the
* mutation must use, so the checked identity is the mutated one (no
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
@@ -130,8 +123,9 @@ export class SandboxedFileSystem extends LocalFileSystem {
* refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
* and the escalation hint.
*/
private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise<FsTarget> {
const mode = sandboxMode ?? this.defaultMode
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const { mode } = policy
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
@@ -141,7 +135,7 @@ export class SandboxedFileSystem extends LocalFileSystem {
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
let contained = false
for (const root of this.writableRoots) {
for (const root of writableRoots(policy)) {
if (await isPathUnder(fresh.targetKey, root)) {
contained = true
break

View File

@@ -1,5 +1,5 @@
/**
* Tests for the sandbox-enforcing filesystem backend: the per-call mode fence
* Tests for the sandbox-enforcing filesystem backend: the per-call policy fence
* on write/edit (read-only denies, workspace-write contains, danger-full-access
* passes through), reads always passing through, the capability fact, and the
* containment matrix — `..` traversal, absolute paths outside, and symlink
@@ -194,12 +194,12 @@ describe('danger-full-access', () => {
})
})
describe('the per-call mode override (escalation)', () => {
describe('the per-call policy override (escalation)', () => {
it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => {
await boot('read-only')
const path = join(workspace, 'escalated.txt')
// Default read-only would deny; the per-call workspace-write stamp allows it (contained).
await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write')
// Default read-only would deny; the per-call workspace-write policy allows it (contained).
await fs.writeText(await target(path), 'granted', undefined, undefined, { mode: 'workspace-write', workspaceRoot: workspace })
expect(await readFile(path, 'utf8')).toBe('granted')
// A neighboring plain call still runs under the read-only default.
await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x'))
@@ -209,7 +209,7 @@ describe('the per-call mode override (escalation)', () => {
it('a danger-full-access stamp bypasses the fence for that call', async () => {
await boot('read-only')
const path = join(outside, 'granted-full.txt')
await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access')
await fs.writeText(await target(path), 'full', undefined, undefined, { mode: 'danger-full-access', workspaceRoot: workspace })
expect(await readFile(path, 'utf8')).toBe('full')
})
})

View File

@@ -7,7 +7,7 @@
*/
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {
FsDirEntry,
FsEditOutcome,
@@ -170,9 +170,9 @@ export abstract class FileSystem extends Service {
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call sandbox mode this write runs under; a
* sandboxing backend fences the write by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @param sandboxPolicy - the per-call mode and workspace root this write
* runs under; a sandboxing backend fences the write by it, the bare backend
* ignores it. Omit to leave the backend its own default.
* @returns the outcome, including the version the write produced.
*/
abstract writeText(
@@ -180,7 +180,7 @@ export abstract class FileSystem extends Service {
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome>
/**
@@ -191,9 +191,9 @@ export abstract class FileSystem extends Service {
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @param sandboxMode - the per-call sandbox mode this edit runs under; a
* sandboxing backend fences the edit by it, the bare backend ignores it.
* Omit to leave the backend its own default.
* @param sandboxPolicy - the per-call mode and workspace root this edit runs
* under; a sandboxing backend fences the edit by it, the bare backend
* ignores it. Omit to leave the backend its own default.
* @returns the outcome, including the version the edit produced.
*/
abstract editText(
@@ -201,7 +201,7 @@ export abstract class FileSystem extends Service {
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome>
}

View File

@@ -36,7 +36,7 @@ class ProbeSuccessBashExecutor extends BashExecutor {
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}

View File

@@ -75,7 +75,7 @@ class FakeBash extends BashExecutor {
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...this.forwardSignal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {

View File

@@ -110,10 +110,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
async execute(args: EditToolArgs, exec) {
const input = parseEditArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes.
const sandboxMode = await sandbox.stampMode('edit', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes.
const sandboxPolicy = await sandbox.resolvePolicy('edit', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
// No stat — the bare default never manufactures a version basis.
@@ -125,11 +125,11 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
intent,
exec.signal,
sandboxMode,
sandboxPolicy,
)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
throw sandbox.mapError(error, sandboxMode)
throw sandbox.mapError(error, sandboxPolicy)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)

View File

@@ -64,7 +64,7 @@ export function apply(ctx: Context, config: Config): void {
streamMinSize: resolved.readStreamMinSize,
})
// One escalation surface shared by both mutating tools: advertisement gating,
// per-call mode stamping, and denial-marker mapping, all keyed off whether
// per-call policy resolution, and denial-marker mapping, all keyed off whether
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
const sandbox = new FsSandboxSurface(ctx)
applyWriteTool(ctx, sandbox)

View File

@@ -123,7 +123,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
isConcurrencySafe: () => true,
async execute(args, exec) {
const input = parseReadArgs(args, caps.limit)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath))
// One stat: type check + size routing + the version recorded as observed.
// A concurrent write can only make a later guarded mutation fail stale and require reread.

View File

@@ -1,6 +1,6 @@
/**
* The sandbox-escalation surface shared by the `write` and `edit` tools: the
* per-call mode stamp, the advertised escalation fields, and the denial-marker
* per-call policy resolution, the advertised escalation fields, and the denial-marker
* mapping — all delegating the vocabulary and the fail-closed approval
* sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
* uses), so bash and fs escalate identically. Built ONCE per plugin from
@@ -12,9 +12,9 @@
import type { Context } from 'cordis'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { FsError } from '@deepseek-ai/dsh-fs'
/** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */
@@ -30,20 +30,23 @@ export interface EscalationSchemaFields {
}
/**
* The filesystem escalation surface: advertisement gating, per-call mode
* stamping (folding the session's `sandbox/mode` override), the one-approved
* wider retry, and denial-marker mapping. A pure product of `ctx` at plugin
* apply time.
* The filesystem escalation surface: advertisement gating, per-call policy
* resolution, the one-approved wider retry, and denial-marker mapping. A pure
* product of `ctx` at plugin apply time.
*/
export class FsSandboxSurface {
/** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
readonly escalationModes: readonly SandboxMode[]
/** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */
private readonly defaultMode: SandboxMode | undefined
/** Shared per-session policy resolver, required by a confining backend. */
private readonly policy: SandboxPolicyService | undefined
constructor(private readonly ctx: Context) {
this.defaultMode = ctx.fs.sandboxMode
this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
const defaultMode = ctx.fs.sandboxMode
this.escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS
this.policy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (defaultMode !== undefined && this.policy === undefined) {
throw new Error('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
}
}
/**
@@ -70,37 +73,29 @@ export class FsSandboxSurface {
}
/**
* The session's standing mode override for an ordinary (non-escalating)
* call — the `sandbox/mode` fold of the calling agent's log. Undefined for a
* non-confining backend and for agent-less callers.
*/
private sessionOverride(exec: ToolExecution): SandboxMode | undefined {
if (this.defaultMode === undefined || exec.agent === undefined) return undefined
return effectiveSandboxMode(exec.agent.session.events)
}
/**
* The mode to STAMP onto this mutation: an approved escalation grant (a
* The policy to stamp onto this mutation: an approved escalation grant (a
* strictly wider retry resolved through `ctx.approval` before anything
* executes), else the session's standing override, else `undefined` (the
* backend applies its own default). Validates the escalation argument
* executes), else the session's standing mode. The calling session's cwd is
* always carried as the workspace root. Validates the escalation argument
* pairing first.
* @param toolName - the mutating tool's name, for the approval audit trail.
* @param args - the call's escalation arguments.
* @param exec - the tool-execution context (agent, callId, signal).
* @returns the mode to pass to the mutation, or undefined for the backend default.
* @returns the policy to pass to the mutation, or undefined for an
* unsandboxed backend.
*/
async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
async resolvePolicy(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxExecutionPolicy | undefined> {
validateEscalationArgs(args.sandbox_permissions, args.justification)
const standingPolicy = this.policy?.resolve({ ...exec.agent ? { session: exec.agent.session } : {} })
if (args.sandbox_permissions === undefined || args.justification === undefined) {
return this.sessionOverride(exec)
return standingPolicy
}
if (this.escalationModes.length === 0) {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)')
}
const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode
return approveEscalation(
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' },
const policy = standingPolicy as SandboxExecutionPolicy
const approvedMode = await approveEscalation(
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode: policy.mode, subject: 'operation' },
{
approver: this.ctx.get('approval'),
agent: exec.agent,
@@ -109,6 +104,7 @@ export class FsSandboxSurface {
signal: exec.signal,
},
)
return { ...policy, mode: approvedMode }
}
/**
@@ -122,14 +118,14 @@ export class FsSandboxSurface {
* confining backend, which always advertises the escalation fields, so the
* hint always applies here.
* @param error - the error thrown by the mutation.
* @param stampedMode - the mode stamped onto the call (names the mode in the marker).
* @param policy - the policy stamped onto the call (names the mode in the marker).
* @returns the error to throw — the marker `FsError` for a sandbox denial, else the original.
*/
mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
// A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode
// (hence the resolved mode) is defined here.
const mode = (stampedMode ?? this.defaultMode) as SandboxMode
// A FS_SANDBOX_DENIED only arises under a confining backend, whose tool
// path always resolves a policy before mutation.
const mode = (policy as SandboxExecutionPolicy).mode
return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
}
}

View File

@@ -9,23 +9,36 @@
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/
/**
* The session workspace cwd for this call, or `undefined` when none applies.
* @param exec - the tool-execution context; only its optional `agent` is read.
* @param requestedPath - the path the provider will resolve; parent traversal
* makes a symlinked cwd's filesystem identity observable.
* @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
*/
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
export function sessionCwd(exec: ToolExecution, requestedPath: string): string | undefined {
const cwd = exec.agent?.session.header.cwd
if (cwd === undefined || (!PARENT_PATH_SEGMENT.test(cwd) && !PARENT_PATH_SEGMENT.test(requestedPath))) return cwd
return canonicalPath(cwd)
}
/**
* Resolution options shared by all model-facing filesystem tools.
* @param exec - the tool-execution context supplying session cwd and cancellation.
* @param requestedPath - the path the provider will resolve.
* @param policyWorkspaceRoot - resolved per-call root, when a mutation carries sandbox policy.
* @returns provider resolution options for the current tool call.
*/
export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; signal?: AbortSignal } {
const cwd = sessionCwd(exec)
export function sessionResolveOptions(
exec: ToolExecution,
requestedPath: string,
policyWorkspaceRoot?: string,
): { cwd?: string; signal?: AbortSignal } {
const cwd = policyWorkspaceRoot ?? sessionCwd(exec, requestedPath)
return {
...cwd !== undefined ? { cwd } : {},
signal: exec.signal,

View File

@@ -100,21 +100,21 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
},
async execute(args: WriteToolArgs, exec) {
const input = parseWriteArgs(args)
// Resolve the per-call sandbox mode (escalation grant > session override
// > backend default) BEFORE anything executes; an escalating call
// resolves approval here and throws its distinct text on any non-grant.
const sandboxMode = await sandbox.stampMode('write', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// Resolve the per-call sandbox policy (approved mode > session override
// > backend default, plus the session cwd root) BEFORE anything executes;
// an escalating call throws its distinct text on any non-grant.
const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
let outcome: FsWriteOutcome
try {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode)
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker (the model
// recognizes it from bash); any other error passes through.
throw sandbox.mapError(error, sandboxMode)
throw sandbox.mapError(error, sandboxPolicy)
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)

View File

@@ -5,6 +5,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -24,8 +27,10 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
import { sessionCwd } from '../src/session-cwd.ts'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
const testToolSignal = new AbortController().signal
@@ -107,6 +112,32 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('session cwd resolution', () => {
const execution = (cwd?: string) => cwd === undefined
? {}
: { agent: { session: { header: { cwd } } } }
it('retains ordinary spelling but resolves the cwd before parent traversal', () => {
const cwd = process.cwd()
const throughParent = `${cwd}${sep}..`
expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined()
expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd)
expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent))
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-'))
const physical = join(root, 'physical')
const link = join(root, 'link')
try {
mkdirSync(physical)
symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir')
expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link)
expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link))
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('registration', () => {
it('registers read, write, and edit', async () => {
const { ctx } = await setup()
@@ -610,9 +641,9 @@ describe('read caps are plugin config', () => {
})
describe('sandbox escalation surface (write/edit)', () => {
/** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */
/** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */
class SandboxingFakeFs extends FakeFs {
stamped: (SandboxMode | undefined)[] = []
stamped: (SandboxExecutionPolicy | undefined)[] = []
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
@@ -621,9 +652,9 @@ describe('sandbox escalation surface (write/edit)', () => {
content: string,
expected?: FsWriteIntent,
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsWriteOutcome> {
this.stamped.push(sandboxMode)
this.stamped.push(sandboxPolicy)
return super.writeText(target, content, expected)
}
override async editText(
@@ -631,9 +662,9 @@ describe('sandbox escalation surface (write/edit)', () => {
edit: FsEditRequest,
expected?: { version: FsVersion },
_signal?: AbortSignal,
sandboxMode?: SandboxMode,
sandboxPolicy?: SandboxExecutionPolicy,
): Promise<FsEditOutcome> {
this.stamped.push(sandboxMode)
this.stamped.push(sandboxPolicy)
return super.editText(target, edit, expected)
}
}
@@ -642,6 +673,7 @@ describe('sandbox escalation surface (write/edit)', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' })
await ctx.plugin(SandboxingFakeFs)
await ctx.plugin(FsPolicy)
if (opts.approval === true) await ctx.plugin(ApprovalService)
@@ -654,7 +686,7 @@ describe('sandbox escalation surface (write/edit)', () => {
return {
id: 'agent-fs-esc',
session: {
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' },
events: [{ type: 'turn/start' }, ...events],
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
},
@@ -667,6 +699,14 @@ describe('sandbox escalation surface (write/edit)', () => {
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
}
it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SandboxingFakeFs)
await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
})
it('advertises no escalation fields under a non-confining backend', async () => {
const { ctx } = await setup()
expect(ctx.fs.sandboxMode).toBeUndefined()
@@ -686,16 +726,16 @@ describe('sandbox escalation surface (write/edit)', () => {
}
})
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
it('a plain write stamps the default mode with the calling session root', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([undefined])
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
})
it('a standing session override folds onto the stamp', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
expect(fs.stamped).toEqual(['read-only'])
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
@@ -728,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => {
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual(['danger-full-access'])
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {

View File

@@ -27,7 +27,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
sandboxMode: request.sandboxMode,
sandboxPolicy: request.sandboxPolicy,
}
},
async run(spec: BashExecSpec): Promise<BashRunResult> {

View File

@@ -2,7 +2,7 @@
Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own.
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No TLS, auth, or origin policy** — the server binds `0.0.0.0` and trusts its network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
- **No TLS, auth, or origin policy** — callers that bind a non-loopback address expose the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
- **`port` is the only listen knob** — bind address and socket options are fixed until a deployment needs them.
- **Socket options are fixed** — callers select the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.

View File

@@ -10,6 +10,7 @@
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { dirname } from 'node:path'
import { serveStatic } from './static.ts'
import type { HostWebPluginRegistry } from './web-plugins.ts'
@@ -21,7 +22,9 @@ export type {
/** Options for startWebServer. */
export interface WebServerOptions {
/** Port to listen on (0.0.0.0). */
/** Address or hostname to listen on. */
host: string
/** Port to listen on; zero requests an OS-assigned port. */
port: number
/**
* Absolute path of index.html inside the static root — the caller resolves
@@ -40,7 +43,7 @@ export interface WebServerOptions {
/** Listening web server handle. */
export interface RunningWebServer {
/** The listening port (for the shell's URL line; equals options.port). */
/** The listening port, including the OS-assigned value when options.port is zero. */
port: number
/**
* Shutdown: close + closeAllConnections (SSE connections never end on their
@@ -50,7 +53,7 @@ export interface RunningWebServer {
}
/**
* Start the web-shape HTTP server: listen(port, '0.0.0.0').
* Start the web-shape HTTP server on the caller-selected host and port.
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
@@ -63,7 +66,7 @@ export interface RunningWebServer {
* @returns the running server handle once listening.
*/
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
const { port, distIndex, apiHandler, webPlugins } = options
const { host, port, distIndex, apiHandler, webPlugins } = options
const distRoot = dirname(distIndex)
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
const html = await readFile(distIndex, 'utf8')
@@ -113,10 +116,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error)
return new Promise((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(port, '0.0.0.0', () => {
server.listen(port, host, () => {
server.off('error', rejectListen)
server.on('error', onError)
resolveListen({ port, close })
resolveListen({ port: (server.address() as AddressInfo).port, close })
})
})
}

View File

@@ -1,16 +1,16 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { createServer as createNetServer, type AddressInfo } from 'node:net'
import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startWebServer, type RunningWebServer } from '../src/index.ts'
/** RunningWebServer.port echoes options.port, so tests must pick a concrete free port up front. */
/** Reserve a loopback port for tests that need to address a second server. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createNetServer()
probe.once('error', reject)
probe.listen(0, () => {
probe.listen(0, '127.0.0.1', () => {
const port = (probe.address() as AddressInfo).port
probe.close(() => { resolve(port) })
})
@@ -107,16 +107,15 @@ afterEach(async () => {
async function boot(onError: (err: Error) => void = () => undefined): Promise<string> {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, onError)
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError)
return `http://127.0.0.1:${String(server.port)}`
}
describe('startWebServer', () => {
it('reports the listening port and closes idempotently', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBe(port)
server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined)
expect(server.port).toBeGreaterThan(0)
const first = server.close()
const second = server.close()
expect(second).toBe(first)
@@ -124,11 +123,33 @@ describe('startWebServer', () => {
server = undefined
})
it.each(['127.0.0.1', '0.0.0.0'])('forwards bind address %s without opening a socket', async (host) => {
const { distIndex } = makeDist()
const port = 3080
const listen = vi.spyOn(NetServer.prototype, 'listen').mockImplementation(function (
this: NetServer, ...args: unknown[]
): NetServer {
const callback = args.at(-1)
if (typeof callback !== 'function') throw new TypeError('listen callback missing')
queueMicrotask(callback as () => void)
return this
})
const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port })
try {
const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined)
expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function))
await inertServer.close()
} finally {
address.mockRestore()
listen.mockRestore()
}
})
it('rejects when the port is already taken', async () => {
const { distIndex } = makeDist()
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ port, distIndex, apiHandler: echoingApi }, () => undefined))
server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)
await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined))
.rejects.toMatchObject({ code: 'EADDRINUSE' })
})
})
@@ -185,7 +206,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
return `http://127.0.0.1:${String(server.port)}`
}
@@ -221,7 +244,9 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
clientPath: () => '/nonexistent/lib/client.js',
}
const port = await freePort()
server = await startWebServer({ port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined)
server = await startWebServer(
{ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
expect(res.status).toBe(404)
})

View File

@@ -40,10 +40,15 @@ function serializeAssistant(message: Message): WireMessage {
return {
role: 'assistant',
// Tool-call turns send "" rather than null: the live API answers both,
// but the official samples replay message.content verbatim (which is ""
// for pure tool-call responses) and some gateways reject null outright.
content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null,
// Text-less turns send "" — NEVER null. Pure tool-call turns: the
// official samples replay message.content verbatim (which is "") and
// some gateways reject null outright. Reasoning-ONLY turns (the model
// can answer entirely in the reasoning channel, e.g. a v4-flash
// greeting): the live API rejects null-content/no-tool_calls assistant
// messages with a 400 ("content or tool_calls must be set"), and since
// the message sits durably in the session log, a null here bricks every
// later turn of that session.
content: text,
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
// must return on tool-call turns; it is ignored on plain turns, so we
// drop it there to save tokens.

View File

@@ -187,12 +187,22 @@ describe('serializeRequest', () => {
})
})
describe('assistant empty and tool-call content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as null content', () => {
// Aborted/empty assistant turns: no text, no calls → null (the wire
// accepts it; "" is reserved for tool-call turns per the samples).
describe('review fixes: assistant content shapes', () => {
it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => {
// Aborted/empty assistant turns: no text, no calls → "". The earlier
// null shape was live-falsified: the API 400s a null-content assistant
// message without tool_calls ("content or tool_calls must be set").
const wire = serializeMessages([{ role: 'assistant', content: [] }])
expect(wire).toEqual([{ role: 'assistant', content: null }])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
it('serializes a reasoning-ONLY assistant message as "" content with the reasoning dropped', () => {
// The model can answer entirely in the reasoning channel (a v4-flash
// greeting did, live). The passback rule keeps reasoning_content off
// plain turns, and content must still be SET — a null here poisoned the
// session log and bricked every later turn of that session.
const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
it('serializes tool-call turns with empty string content, not null', () => {

View File

@@ -33,7 +33,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.79.1",
"@earendil-works/pi-ai": "^0.81.1",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -4,13 +4,11 @@
* @module dsh-llm-pi-ai/adapter
*/
import {
getModels,
streamSimple,
} from '@earendil-works/pi-ai'
import { streamSimple } from '@earendil-works/pi-ai/compat'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type {
Api,
KnownProvider,
Model,
SimpleStreamOptions,
} from '@earendil-works/pi-ai'
@@ -33,7 +31,7 @@ export interface PiAiAdapterOptions {
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
const model = getModels(profile.provider as KnownProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
}
@@ -82,7 +80,7 @@ export class PiAiAdapter extends LlmAdapter {
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({
return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({
provider,
id: model.id,
name: model.name,

View File

@@ -4,7 +4,7 @@
* @module dsh-llm-pi-ai/config
*/
import { getProviders } from '@earendil-works/pi-ai'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -62,7 +62,7 @@ const profile = z.object({
apiKey: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh']),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
thinkingBudgets,
cacheRetention: z.union(['none', 'short', 'long']),
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
@@ -84,7 +84,7 @@ export const Config: z<Config> = z.object({
*/
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
const supported = new Set<string>(getProviders())
const supported = new Set<string>(getBuiltinProviders())
const seen = new Set<string>()
return profiles.map((source) => {
const legacy = source as PiAiProviderProfile & {

View File

@@ -5,8 +5,8 @@ import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { getModels } from '@earendil-works/pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts'
@@ -244,7 +244,7 @@ describe('PiAiAdapter provider routing', () => {
})
it('uses the resolved catalog context window for usage-based overflow detection', async () => {
const model = getModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash')
const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash')
if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog')
const events = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',

View File

@@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
const streamSimple = vi.hoisted(() => vi.fn())
vi.mock('@earendil-works/pi-ai', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai')>()
// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it
// from there, so the mock must target the same specifier.
vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai/compat')>()
return { ...actual, streamSimple }
})

View File

@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -38,12 +38,27 @@ function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<Lsp
}
/** Mount the real seam + lsp-local plugin driving one fake server. */
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
async function mount(
fakeEnv: Record<string, string> = {},
overrides: Partial<LspLocalServerConfig> = {},
captureProvider?: (provider: LspProvider) => void,
): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
: vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
captureProvider(provider)
return register(provider)
})
try {
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
} finally {
registrationSpy?.mockRestore()
}
return ctx
}
@@ -247,10 +262,22 @@ describe('lsp-local end to end over a fake server', () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
let provider: LspProvider | undefined
const ctx = await mount(
{ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
{},
(registered) => { provider = registered },
)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
await new Promise(resolve => setTimeout(resolve, 60))
if (provider === undefined) throw new Error('expected lsp-local to register a provider')
// This implementation-local test reaches the private pool only to synchronize with its actual
// close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
const instances = (provider as unknown as {
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
}).instances
const instance = [...instances.values()][0]
if (instance === undefined) throw new Error('expected one pooled LSP instance')
await waitFor(async () => instance.dead)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})

9
packages/plan/README.md Normal file
View File

@@ -0,0 +1,9 @@
# plan/ — plan collaboration state
Plan mode is one logged, per-agent collaboration state. It is a single **product** package, not a generic mode registry or a capability-seam trio.
| Package | Role | ctx key |
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]`, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -0,0 +1,88 @@
# @deepseek-ai/dsh-plan-mode
Logged, per-agent plan collaboration state with deployment-owned guidance, a direct `/plan [message]` entry command, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
## Durable state
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state.
## Model and human surfaces
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
When `ctx.commands` is composed, the package registers `/plan [message]`. The command selects plan mode first. A non-empty argument is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance; bare `/plan` only changes state.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
## Configuration
```yaml
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Explore and design before presenting the complete
plan through exit_plan_mode.
```
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
## Model Experience
### Plan policy system prompt
#### What the model sees
While plan mode is active, the model sees the deployment's exact `section` text at prompt order 50; inactive mode contributes no text.
##### Configuration example
```markdown
You are in plan mode. Explore and design before presenting the complete plan through exit_plan_mode.
```
#### Token effect
Inactive mode adds no tokens; active mode adds the configured section to every request.
#### KV Cache effect
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
### Optional command message
#### What the model sees
`/plan` and its terminal result stay outside model history; a non-empty suffix becomes one trimmed user text block through `agent.steer()` after plan mode is selected.
#### Token effect
The suffix costs the same history tokens as submitting that text separately; a bare command adds none.
#### KV Cache effect
The user block is append-only conversation growth, while entering plan mode also changes the earlier policy section.
### Exit tool schema and review exchange
#### What the model sees
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback.
#### Token effect
The stable schema is paid according to ToolRegistry mode, and each plan argument and review result remains in conversation history.
#### KV Cache effect
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
## Known Limitations and Deferred Work
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.

View File

@@ -0,0 +1,57 @@
{
"name": "@deepseek-ai/dsh-plan-mode",
"description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-commands": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,352 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
* deployment-owned guidance section shapes each model request, and
* `exit_plan_mode` presents the completed plan for user review. It is
* independent of sandbox mode and approval policy; those enforcement axes do
* not read or write plan state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until a turn boundary because every session event
* is turn-enclosed. The service flushes before the affected request assembly
* on prompt submission, ordinary continuation, and request-recovery retry.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Notes:
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
* inactive through {@link foldPlanMode}.
*/
'plan/mode': { active: boolean }
}
}
declare module 'cordis' {
interface Context {
planMode: PlanModeService
}
}
/**
* The model-facing exit tool's name. It stays registered while plan mode is
* inactive so the request tool catalog is stable across transitions.
*/
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/** Deployment-owned plan guidance. */
export interface PlanModeConfig {
/** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
section: string
}
/** The review question's approve option label. */
const APPROVE_LABEL = 'Approve'
/** The review question's keep-planning option label. */
const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
+ 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
+ 'The user may approve (carry out the plan from your next step) or keep '
+ 'planning — their feedback comes back in the tool result; revise and present again.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
function firstHeading(plan: string): string | undefined {
for (const line of plan.split('\n')) {
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line)
if (match) return match[1]
}
return undefined
}
/**
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
* unknown fields fail at plugin load rather than silently shaping nothing.
*
* @param config Raw plugin config.
* @returns A detached validated config.
*/
export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
const section = (config as Partial<PlanModeConfig>).section
if (typeof section !== 'string') {
throw new Error('PlanModeConfig needs a string `section`')
}
if (section.trim() === '') {
throw new Error('PlanModeConfig needs a non-empty `section`')
}
const unknown = Object.keys(config).filter(key => key !== 'section')
if (unknown.length > 0) {
throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`)
}
return { section }
}
/**
* Whether plan mode is active after the first `end` events. The last
* `plan/mode` wins; a prefix with none is inactive.
*
* @param events The session log or any prefix of it.
* @param end Fold `events[0, end)`; defaults to the whole log.
* @returns Whether plan mode is active.
*/
export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
let active = false
let index = 0
for (const event of events) {
if (index >= end) break
index++
if (event.type === 'plan/mode') active = event.data.active
}
return active
}
/** Plan state at the last logged request header, or `undefined` before the first header. */
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
let lastHeader = -1
let index = 0
for (const event of events) {
if (event.type === 'request/header') lastHeader = index
index++
}
if (lastHeader < 0) return undefined
return foldPlanMode(events, lastHeader + 1)
}
/**
* `ctx.planMode`: owns logged plan state, boundary application and narration,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* UIs observe committed flips through `session/event`; there is no live mirror.
*/
export class PlanModeService extends Service {
static inject = ['tools', 'systemPrompt']
/** Validated deployment-owned guidance. */
private readonly section: string
/**
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
* true for user selections and false for the exit tool, whose result already
* narrates the transition.
*/
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
let disposed = false
// Boundary flushes use loop interception seams, not post-commit
// `session/event` observation. Flush after next(): a selection arriving
// while a downstream async listener awaits must still shape the request
// this boundary precedes. Failures are contained so policy cannot block a
// prompt or turn; a failed append remains pending for a later boundary.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can retain this wrapper after Cordis unregisters it.
if (disposed || decision.action !== 'retry') return decision
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
ctx.systemPrompt.section({
name: 'plan:policy',
order: 50,
text: context => context.agent !== undefined && foldPlanMode(context.agent.session.events)
? this.section
: '',
})
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'plan',
description: 'Enter plan mode',
input: { hint: '[message]' },
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
return { kind: 'success', text: 'Entering plan mode (applies from the next step).' }
},
})
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,
parameters: {
plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
approved: { type: 'boolean', const: true, required: true },
},
},
render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
},
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
if (!foldPlanMode(agent.session.events)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`)
}
const interaction = ctx.get('userInteraction')
if (interaction === undefined) {
throw new Error('no user-interaction channel is available to review the plan; ask the user to switch the session mode instead')
}
const answer = await interaction.ask({
questions: [{
id: 'plan-review',
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: args.plan,
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
agent,
signal: exec.signal,
})
// A review may outlive this plugin fiber. Without boundary listeners,
// an approved result could never land, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
const feedback = item?.custom ?? ''
throw new Error(feedback === ''
? 'The user chose to keep planning; revise the plan and present it again.'
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// Keep plan guidance for the rest of this assistant tool batch. The
// silent intent flushes after the step, before the next assembly.
this.pendingIntents.set(agent.session, { active: false, narrate: false })
return { approved: true }
},
presentCall: args => ({
card: 'generic',
title: firstHeading(args.plan) ?? 'Plan',
kind: 'other',
content: [{ type: 'text', text: args.plan }],
}),
presentResult: (_args, result) => ({
card: 'generic',
title: 'Plan review',
content: result.content,
}),
}))
}
/**
* Read the logged plan state and any selected state awaiting a boundary.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean } {
const active = foldPlanMode(agent.session.events)
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { active } : { active, pending: pending.active }
}
/**
* Select whether plan mode should be active from the next turn boundary.
* Repeated selection of the current or already-pending state is a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
*/
set(agent: Agent, active: boolean): void {
const session = agent.session
const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
if (active === target) return
this.pendingIntents.set(session, { active, narrate: true })
}
/** Flush one pending selection before the next request assembly. */
private onBoundary(agent: Agent): void {
const session = agent.session
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
if (target === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return
}
session.append('plan/mode', { active: target })
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (!pending.narrate) return
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
: 'The user switched this session back to the default mode.'
session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'plan-mode' },
}, { surfaceOp: 'append' })
}
}
export default PlanModeService

View File

@@ -0,0 +1,43 @@
/** Package-owned durable plan-mode invariants. @module @deepseek-ai/dsh-plan-mode/invariant */
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-plan-mode'
/** Cordis companion plugin name. */
export const name = 'plan-mode-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate one `plan/mode` payload before it reaches the durable log. */
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
if (event.type !== 'plan/mode') return
const active = (event.data as { active?: unknown }).active
if (typeof active !== 'boolean') {
fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install validation for loaded and newly appended plan-mode state. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(event, fail)
}
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const event = (args as [Session, SessionEvent])[1]
validateEvent(event, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the plan-mode invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
/**
* Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
* through the agent loop — the pending-intent flush at the turn boundary, the
* assembly the soft layer shapes (the exit tool + mode section), and the
* `request/header` snapshots every transition leaves.
* Only the model is mocked; the loop, the session log, and the plugin are
* real.
*/
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(PlanModeService, PLAN_CONFIG)
ctx.llm.registerAdapter(['mock'], adapter)
for (const name of ['read', 'write']) {
ctx.tools.register(defineContentToolFixture({
name,
description: `test tool ${name}`,
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
}))
}
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function findEvent<T extends SessionEvent['type']>(
log: readonly SessionEvent[],
type: T,
position: 'first' | 'last' = 'first',
): Extract<SessionEvent, { type: T }> {
const found = position === 'first'
? log.find(event => event.type === type)
: log.findLast(event => event.type === type)
if (!found) throw new Error(`no ${type} event in the session log`)
return found as Extract<SessionEvent, { type: T }>
}
describe('plan mode through the agent loop', () => {
it('a pre-turn set() makes the FIRST header plan-shaped, and a non-shell call is guidance-constrained only', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
textResponse('Noted in the plan.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle (the ACP picker shape): the pending intent flushes at
// the first prompt-submit, BEFORE the first assembly.
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'explore the repo' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
const planMode = findEvent(log, 'plan/mode')
const header = findEvent(log, 'request/header')
expect(planMode.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(header.data.header.system).toContain('plan mode')
// No tool gate: the write RUNS — plan restrains by the section's
// guidance alone (enforcement lives on the independent sandbox/approval
// axes). The mode itself stays plan throughout.
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(false)
expect(foldPlanMode(log)).toBe(true)
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => {
const adapter = new MockAdapter([
textResponse('First turn, default mode.'),
textResponse('Second turn, plan mode.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(foldPlanMode(agent.session.events)).toBe(false)
const first = findEvent(agent.session.events, 'request/header')
expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
ctx.planMode.set(agent, true)
agent.send([{ type: 'text', text: 'now plan' }])
await waitForIdle(ctx, agent)
const log = agent.session.events
expect(foldPlanMode(log)).toBe(true)
const notices = log.filter(event => event.type === 'context/message')
expect(notices).toHaveLength(1)
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
// The changed request is logged as a complete snapshot.
const second = findEvent(log, 'request/header', 'last')
expect(second.data.reason).toBe('change')
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(second.data.header.tools).toEqual(first.data.header.tools)
expect(second.data.header.system).toContain('plan mode')
})
it('a mode flip during request recovery shapes the retry before its assembly', async () => {
const failedRequest = [{
type: 'finish',
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
}] satisfies StreamChunk[]
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => {
if (subject !== agent) return next()
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
})
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.planMode.set(agent, true)
releaseRecovery.resolve(true)
await idle
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section)
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(PlanModeInvariant)
return ctx
}
function event(active: unknown): SessionEvent {
return { type: 'plan/mode', seq: 0, time: 0, data: { active } } as SessionEvent
}
describe('plan-mode stream invariants', () => {
it('accepts either boolean state', async () => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event(true)) }).not.toThrow()
expect(() => { ctx.emit('session/event', {} as Session, event(false)) }).not.toThrow()
})
it.each([42, 'plan', undefined])('rejects invalid durable plan state %j', async (active) => {
const ctx = await setup()
expect(() => { ctx.emit('session/event', {} as Session, event(active)) })
.toThrow(/expected a boolean/)
})
it('ignores unrelated dispatches and session events', async () => {
const ctx = await setup()
expect(() => {
ctx.emit('tools/change')
ctx.emit('session/event', {} as Session, {
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
})
}).not.toThrow()
})
it('rejects invalid existing state on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.sessions.create().append('plan/mode', { active: 'plan' as unknown as boolean })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/expected a boolean/)
})
})

View File

@@ -0,0 +1,940 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
/**
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Turn boundaries are simulated by appending the real boundary events and
* dispatching the interception seams the loop fires there. Recovery retries
* exercise the separate `agent/request-error` wrapper.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
}))
;(agent as { ctx?: Context }).ctx = scoped
// Seeded plan state lands before the creation announcement, matching resume.
if (active !== undefined) session.append('plan/mode', { active })
// The loop announces creation after publication.
ctx.emit('agent/created', agent)
return agent
}
/** Assemble exactly as the loop does: the agent is both subject and scope. */
function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
}
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(PlanModeService, config)
return ctx
}
/**
* Append a boundary event and dispatch the interception seam the loop fires
* there — `agent/prompt-submit` inside the just-opened turn,
* `agent/turn-continuation` after the step closed. Recovery retries use the
* separately covered `agent/request-error` wrapper; post-commit
* `session/event` observers remain observe-only.
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
return
}
agent.session.append('step/end', { turn: 1, step: 1 })
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
}
/** Dispatch the closed-step recovery seam with one terminal decision. */
function recoveryBoundary(
ctx: Context,
agent: Agent & { session: Session },
decision: RequestErrorDecision,
): Promise<RequestErrorDecision> {
return agentEvents(ctx, agent).waterfall(
'agent/request-error',
1,
1,
new Error('request failed'),
{ message: 'request failed', code: 'SERVER' },
[],
new AbortController().signal,
() => Promise.resolve(decision),
)
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
function header(session: Session): void {
session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
}
function noticeTexts(session: Session): string[] {
return session.events
.filter(event => event.type === 'context/message')
.map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join(''))
}
function registerNamedTools(ctx: Context, names: string[]): void {
for (const name of names) {
ctx.tools.register(defineContentToolFixture({
name,
description: `test tool ${name}`,
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
}))
}
}
/** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */
function expectPlanCodeSdkBindings(sdk: string): void {
expect(sdk).toContain('interface ToolArgsMap {')
expect(sdk).toContain('read: Record<string, JsonValue>;')
expect(sdk).toContain('write: Record<string, JsonValue>;')
expect(sdk).toContain('interface ToolOutputMap {')
expect(sdk).toContain('exit_plan_mode: {\n approved: true;\n };')
expect(sdk).toContain('[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;')
}
let callCounter = 0
function execute(ctx: Context, name: string, agent?: Agent) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: {},
signal: new AbortController().signal,
...agent ? { agent } : {},
})
}
describe('resolveConfig', () => {
it('requires string, non-empty plan instructions', () => {
expect(() => resolveConfig({} as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ section: 5 } as unknown as PlanModeConfig))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ section: ' ' }))
.toThrow('needs a non-empty `section`')
})
it('returns a detached plan config', () => {
const config = { section: TEST_PLAN_SECTION }
const resolved = resolveConfig(config)
expect(resolved).toEqual(config)
expect(resolved).not.toBe(config)
})
it('rejects fields outside the plan policy config', () => {
expect(() => resolveConfig({ section: TEST_PLAN_SECTION, tools: ['read'] } as unknown as PlanModeConfig))
.toThrow('unknown key(s) tools — config is { section }')
})
})
describe('foldPlanMode', () => {
it('folds an empty log to inactive and takes the last plan/mode otherwise', () => {
const session = new Session(SessionId('fold'))
expect(foldPlanMode(session.events)).toBe(false)
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
session.append('plan/mode', { active: true })
expect(foldPlanMode(session.events)).toBe(true)
})
it('folds a prefix when `end` is given', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('plan/mode', { active: true })
session.append('plan/mode', { active: false })
expect(foldPlanMode(session.events, 1)).toBe(true)
expect(foldPlanMode(session.events, 0)).toBe(false)
})
})
describe('ctx.planMode: get/set', () => {
it('reads the folded state', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(ctx.planMode.get(agent)).toEqual({ active: false })
agent.session.append('plan/mode', { active: true })
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('selects inactive as the plan exit target', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('plan/mode', { active: true })
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, false)
expect(ctx.planMode.get(agent)).toEqual({ active: false })
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, true)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
describe('the boundary flush', () => {
it('flushes the pending intent as a plan/mode at turn/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
// A downstream async listener (the shipped hooks listeners' shape): the
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.planMode.set(agent, true)
await next()
return decision
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
// A downstream listener captured before disposal keeps the waterfall
// continuation alive across the unload; the resumed wrapper must not
// append through the disposed service.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
await fiber.dispose()
await next()
return decision
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('contains an append failure at the retry boundary without changing its decision', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates nothing before the first request header (the section is the state statement)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual([])
})
it('narrates once when the flushed mode differs from what the last header told the model', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
it('narrates a switch back to the default mode with the default wording', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('plan/mode', { active: true })
header(agent.session)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
it('stays silent when the header already reflects the flushed mode', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('plan/mode', { active: true })
header(agent.session)
agent.session.append('plan/mode', { active: false })
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(noticeTexts(agent.session)).toEqual([])
})
it('contains an append failure instead of blocking the prompt or the turn', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
// Only the flush's own plan/mode append fails; the boundary event itself
// lands (the loop appended it before the seam fires).
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
expect(warn).toHaveBeenCalledOnce()
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
agent.session.append = original
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('contains an append failure on the prompt-submit seam the same way', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
})
describe('the soft layer', () => {
it('keeps the tool schemas identical across default and plan mode', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx)
const defaultAssembly = await assembleFor(ctx, agent)
expect(defaultAssembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read', 'write'])
expect(defaultAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
agent.session.append('plan/mode', { active: true })
const planAssembly = await assembleFor(ctx, agent)
expect(planAssembly.tools).toEqual(defaultAssembly.tools)
expect(planAssembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves an agent-less assembly untouched', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read'])
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual([EXIT_PLAN_MODE, 'read'])
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
it('keeps the full toolset in plan mode and renders the configured mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
})
it('leaves foreign assemble additions alone (no assemble-layer filtering)', async () => {
// Plan guidance does not filter the registry or later assembly additions.
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const final = await next()
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read'])
const planning = await agentWithSession(ctx, 'planning', { active: true })
expect((await assembleFor(ctx, planning)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
const defaulted = await agentWithSession(ctx, 'defaulted')
expect((await assembleFor(ctx, defaulted)).tools.map(tool => tool.name))
.toEqual(['exit_plan_mode', 'read', 'added-later'])
})
it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
// assembly time (the code-mode.spec fake's shape).
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
// The SDK documents the full binding set plus the exit; plan mode never
// prunes capabilities and restrains through guidance alone.
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expectPlanCodeSdkBindings(sdk)
})
it('keeps native wire schemas and the SDK in step under mode both', async () => {
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'both' })
await ctx.plugin(FakeRuntime)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const assembly = await assembleFor(ctx, agent)
// The stable registry contribution reaches both surfaces: the exit tool
// is present on the wire AND in the SDK alongside the untouched toolset.
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expectPlanCodeSdkBindings(sdk)
})
it('keeps the Code Mode SDK byte-identical across mode switches', async () => {
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
}
const withPlanMode = new Context()
await withPlanMode.plugin(SystemPrompt)
await withPlanMode.plugin(ToolRegistry, { mode: 'code' })
await withPlanMode.plugin(FakeRuntime)
await withPlanMode.plugin(PlanModeService, PLAN_CONFIG)
registerNamedTools(withPlanMode, ['read', 'write'])
const agent = await agentWithSession(withPlanMode)
const defaultSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expectPlanCodeSdkBindings(defaultSdk)
agent.session.append('plan/mode', { active: true })
const planSdk = (await assembleFor(withPlanMode, agent)).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(planSdk).toBe(defaultSdk)
// Loading the plan-mode plugin deliberately adds one stable binding compared
// with a deployment that does not compose plan mode at all.
const bare = new Context()
await bare.plugin(SystemPrompt)
await bare.plugin(ToolRegistry, { mode: 'code' })
await bare.plugin(FakeRuntime)
registerNamedTools(bare, ['read', 'write'])
const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(bareSdk).not.toContain('exit_plan_mode:')
expect(defaultSdk).not.toBe(bareSdk)
})
})
describe('no execution gating beyond the exit tool', () => {
it('passes agent-less and default-mode executions through', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['write'])
const agentless = await execute(ctx, 'write')
expect(agentless.isError).toBe(false)
const agent = await agentWithSession(ctx)
const defaulted = await execute(ctx, 'write', agent)
expect(defaulted.isError).toBe(false)
})
it('runs every call in plan mode untouched — guidance and enforcement are separate axes', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'bash'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
for (const name of ['read', 'write', 'bash']) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
}
})
})
describe('/plan', () => {
it('registers only when a commands service is composed and optionally submits the next-step message', async () => {
const bare = await setup()
expect(bare.get('commands')).toBeUndefined()
const ctx = await setup()
await ctx.plugin(CommandService)
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
{ name: 'plan', description: 'Enter plan mode', input: { hint: '[message]' } },
])
const signal = new AbortController().signal
expect(await ctx.commands.execute(plainAgent, '/mode', signal)).toBeUndefined()
expect(await ctx.commands.execute(plainAgent, '/review', signal)).toBeUndefined()
const plain = await ctx.commands.execute(plainAgent, '/plan', signal)
expect(plain).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.planMode.get(plainAgent)).toEqual({ active: false, pending: true })
expect(plainSteer).not.toHaveBeenCalled()
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
expect(plan).toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step).' })
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith([{ type: 'text', text: 'draft the migration' }])
})
it('removes the contributed command when the plan-mode plugin is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(CommandService)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
expect(ctx.commands.list(agent).map(command => command.name)).toEqual(['plan'])
await fiber.dispose()
expect(ctx.commands.list(agent)).toEqual([])
})
})
describe('exit_plan_mode', () => {
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
const ctx = await setup()
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
if (answer !== undefined) {
ctx.userInteraction.registerProvider({
ask: (request) => {
asked.push(request)
return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] })
},
})
}
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
return { ctx, agent, asked }
}
function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') {
return ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan },
signal: new AbortController().signal,
...agent ? { agent } : {},
})
}
it('registers the tool with one required plan argument', async () => {
const ctx = await setup()
const schema = ctx.tools.schemas().find(entry => entry.name === EXIT_PLAN_MODE)
const parameters = schema?.parameters as { required?: string[]; properties?: Record<string, unknown> }
expect(schema?.description).toMatch(/^Use only in plan mode\./)
expect(Object.keys(parameters.properties ?? {})).toEqual(['plan'])
expect(parameters.required).toEqual(['plan'])
})
it('rejects an agent-less call', async () => {
const ctx = await setup()
const result = await callExit(ctx, undefined)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a calling agent (no session to switch)' }])
})
it('rejects a call outside plan mode while remaining advertised', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(ctx.tools.schemas().map(tool => tool.name)).toContain(EXIT_PLAN_MODE)
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode is only available in plan mode' }])
})
it('rejects an empty or heading-less plan before asking the reviewer', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
for (const plan of ['', 'do things']) {
const result = await callExit(ctx, agent, plan)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }])
}
expect(asked).toHaveLength(0)
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades to the manual exit when no user-interaction seam is composed', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction channel is available to review the plan; ask the user to switch the session mode instead' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => {
const { ctx, agent } = await setupWithReview()
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-interaction provider is registered' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected approved plan result')
expect(result.value).toEqual({ approved: true })
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
// Boundary-applied, not a direct append: the fold stays plan until the
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things')
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
})
it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => {
const plan = '# Code Mode plan\n\nUse the existing seam.'
class ExitRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
async run(request: CodeRunRequest): Promise<CodeRunResult> {
const exit = request.bindings[0]?.functions[EXIT_PLAN_MODE]
if (exit === undefined) throw new Error('missing exit_plan_mode binding')
return { logs: [], value: await exit({ plan }) }
}
}
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(ExitRuntime)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ask: (request) => {
asked.push(request)
return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
},
})
const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true })
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: RUN_CODE_NAME,
arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })` },
signal: new AbortController().signal,
agent,
})
expect(result.isError).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.questions[0]).toMatchObject({
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
detail: plan,
})
expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({
name: EXIT_PLAN_MODE,
arguments: { plan },
isError: false,
})
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('an approved exit keeps plan guidance until the boundary and never removes the tool', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
const approved = await callExit(ctx, agent)
expect(approved.isError).toBe(false)
// Calls of the SAME assistant response (no boundary between) were
// requested under the plan-shaped header — the fold stays plan for that
// whole batch; the boundary flush is what flips the next step.
expect(foldPlanMode(agent.session.events)).toBe(true)
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('')
})
it('the exit flush narrates nothing — the tool result is the narration', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
header(agent.session)
await callExit(ctx, agent)
await boundary(ctx, agent, 'step/end')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'], custom: 'consider the resume path' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keep planning without feedback returns the generic corrective error', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Keep planning'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
})
it('a custom-text-only answer is feedback, never consent', async () => {
const { ctx, agent } = await setupWithReview({ selected: [], custom: 'add tests first' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('requires exactly the single Approve selection', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve', 'Keep planning'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats custom text alongside Approve as feedback, not consent', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'change the tests' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('treats duplicate review answer items as non-consent', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({
ask: () => Promise.resolve({ answers: [
{ id: 'plan-review', selected: ['Approve'] },
{ id: 'plan-review', selected: ['Keep planning'] },
] }),
})
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a missing answer item reads as keep-planning', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => Promise.resolve({ answers: [] }) })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
})
it('forwards the execution abort signal to the review question', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const controller = new AbortController()
const result = await ctx.tools.execute({
callId: CallId(`call-exit-${++callCounter}`),
name: EXIT_PLAN_MODE,
arguments: { plan: '# P' },
agent,
signal: controller.signal,
})
expect(result.isError).toBe(false)
expect(asked[0]?.signal).toBe(controller.signal)
})
it('fails the call when the plugin is disposed while the review awaits (no phantom exit)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(UserInteractionService)
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
ctx.userInteraction.registerProvider({
ask: () => new Promise((resolve) => { answer = resolve }),
})
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
const pending = callExit(ctx, agent)
// Let execute reach the review await, then unload the plugin (HMR) and
// only afterwards approve. The boundary listeners are gone, so a success
// would claim an exit that can never flush — the call must fail instead.
await new Promise(resolve => setImmediate(resolve))
await fiber.dispose()
answer({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })
const result = await pending
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => {
const { ctx, agent } = await setupWithReview()
ctx.userInteraction.registerProvider({ ask: () => { throw new Error('review aborted') } })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }])
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('presents the call as a generic card titled by the plan first heading', async () => {
const ctx = await setup()
const def = ctx.tools.get(EXIT_PLAN_MODE)!
expect(def.presentCall?.({ plan: '## Fix the flake\n\nsteps' })).toEqual({
card: 'generic',
title: 'Fix the flake',
kind: 'other',
content: [{ type: 'text', text: '## Fix the flake\n\nsteps' }],
})
expect(def.presentCall?.({ plan: 'no heading here' })).toEqual({
card: 'generic',
title: 'Plan',
kind: 'other',
content: [{ type: 'text', text: 'no heading here' }],
})
})
it('presents the result as a generic review card', async () => {
const ctx = await setup()
const def = ctx.tools.get(EXIT_PLAN_MODE)!
const content = [{ type: 'text' as const, text: 'ok' }]
expect(def.presentResult?.({ plan: '# P' }, { content, isError: false })).toEqual({
card: 'generic',
title: 'Plan review',
content,
})
})
})
describe('HMR disposal', () => {
it('does not flush a retry boundary that resumes after plugin disposal', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
})
ctx.planMode.set(agent, true)
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
await recoveryEntered.promise
await fiber.dispose()
releaseRecovery.resolve(true)
expect(await recovery).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-recovery')
ctx.planMode.set(agent, true)
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).toContain('plan:policy')
await fiber.dispose()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/commands"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,12 +1,12 @@
# sandbox/ — process-sandbox capability family
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; a complete `SandboxExecutionPolicy` (mode + workspace root) rides each capability call, and its confined subset becomes the provider's `SandboxPolicy`. Different sessions and consumers can therefore confine under different policies at the same instant. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` |
| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) |
| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` |
| `sandbox-policy/` | The policy resolver: deployment fallbacks plus each session's durable mode and immutable cwd root. Both enforcing families consume its complete per-call result, so bash and fs cannot confine to different roots | `ctx.sandboxPolicy` |
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).

View File

@@ -1,20 +1,21 @@
# dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`)
The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads.
The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call.
## Why a shared home
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision.
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each resolved its own `mode` + `workspaceRoot`, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both tool layers resolve policy through `ctx.sandboxPolicy`, and both enforcing backends consume that complete per-call result. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision.
## Config
- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe).
- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way.
- `workspaceRoot` — the fallback directory `workspace-write` may write under for agentless calls or sessions without a cwd. Default `process.cwd()`, resolved to its absolute filesystem identity either way. A normal agent call uses its session header's immutable `cwd` instead.
## Surface
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary.
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events.
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
@@ -22,7 +23,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve
## The per-session store
A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions never see each other's state, and there is no external config store. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant.
A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event.
## Model Experience
@@ -34,5 +35,5 @@ No direct invalidation; the named consumers own any request-prefix changes, and
## Known Limitations and Deferred Work
- **`workspaceRoot` is process-wide and fixed for the service's lifetime** — a per-session workspace root is a deferred phase of the sandbox RFC; this package centralizing the root is its groundwork, not its design.
- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`.
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-sandbox-policy",
"description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family",
"description": "Per-call sandbox policy resolver (ctx.sandboxPolicy): deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,33 +1,33 @@
/**
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
* deployment's sandbox default — the file-effect {@link SandboxMode} a session
* starts from and the `workspace-write` boundary root — plus the per-session
* override kit (the `sandbox/mode` event, its fold, and its write path, from
* `./session-mode.ts`).
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
* {@link SandboxMode}, the `workspace-write` root, and the override kit (the
* `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
*
* Both enforcing capability families read the SAME policy here: the sandboxed
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
* provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the
* default mode and workspace root, so bash and fs can never confine to
* different roots — the split world the sandbox RFC warns about. The default
* lives here rather than on either executor's config precisely because it is
* one fact two families share.
*
* This service holds only the DEFAULT; the per-session fold
* ({@link effectiveSandboxMode}) is a pure function the tool layers apply to
* stamp each call, so neither the executor nor the provider depends on session
* events.
* provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call
* policy, so bash and fs can never confine to different roots — the split
* world the sandbox RFC warns about. The service reads session state once at
* the tool boundary; executors and providers remain session-free.
*
* @module @deepseek-ai/dsh-sandbox-policy
*/
import { resolve } from 'node:path'
import { resolve as resolvePath } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode } from './session-mode.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
/** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */
function resolveWorkspaceRoot(path: string): string {
return resolvePath(canonicalPath(path))
}
declare module 'cordis' {
interface Context {
sandboxPolicy: SandboxPolicyService
@@ -45,17 +45,25 @@ export interface Config {
/** File-sandbox mode a session starts from (default: `read-only`). */
mode?: SandboxMode
/**
* Absolute root directory `workspace-write` may write under (default:
* `process.cwd()`). Both enforcing families fence against this SAME root.
* Fallback root for agentless calls and sessions without a cwd (default:
* `process.cwd()`). Normal agent calls use their session cwd instead.
*/
workspaceRoot?: string
}
/** Inputs that select the sandbox policy for one capability call. */
export interface SandboxPolicyRequest {
/** Calling session; its immutable cwd becomes the workspace boundary. */
session?: Session
/** Explicit approved mode override, which outranks session policy. */
mode?: SandboxMode
}
/**
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
* default mode and workspace root; enforcing implementations read
* {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each
* session's `sandbox/mode` override with {@link effectiveSandboxMode} on top.
* default mode and fallback workspace root. Tool layers call {@link resolve}
* for each execution so a session's mode log and immutable cwd travel together
* to every enforcing capability.
*/
export class SandboxPolicyService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
@@ -68,7 +76,7 @@ export class SandboxPolicyService extends Service {
/** The deployment default mode — the fallback beneath a session override. */
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` boundary root both families fence against. */
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
constructor(ctx: Context, config: Config) {
@@ -77,7 +85,24 @@ export class SandboxPolicyService extends Service {
// runtime fact. `workspaceRoot` has NO schema default, so its fallback to
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd())
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
}
/**
* Resolve the complete policy for one capability call. An approved explicit
* mode outranks the session's last `sandbox/mode` event, which outranks the
* deployment default. A session cwd is its workspace-write boundary; the
* configured root is the fallback for agentless calls and sessions without a
* cwd.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
const { session } = request
return {
mode: request.mode ?? (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
}
}
}

View File

@@ -7,9 +7,9 @@
* and there is no external config store. The event is log-only (the
* `approval/*` precedent): the model learns the mode from the boundary
* markers in the enforcing tools, never from the event itself. EXECUTION
* honors the fold in each tool layer — it stamps the effective mode onto the
* per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's
* `sandboxMode`), weakest-precedence beneath an escalation grant.
* honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode
* together with the calling session's workspace root onto each capability
* call, weakest-precedence beneath an escalation grant.
*
* The override is policy state shared by every enforcing family (bash and
* filesystem alike), so it lives here in the policy package rather than in any

View File

@@ -4,7 +4,9 @@
* override kit (fold + write path) both enforcing families read.
*/
import { resolve } from 'node:path'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -16,6 +18,16 @@ async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'dange
return ctx
}
function session(id: string, cwd?: string): Session {
const sessionId = SessionId(id)
return new Session(sessionId, undefined, {
version: 0,
id: sessionId,
createdAt: 0,
...cwd === undefined ? {} : { cwd },
})
}
describe('SandboxPolicyService', () => {
it('defaults to read-only under the process cwd', async () => {
const ctx = await mounted()
@@ -29,6 +41,71 @@ describe('SandboxPolicyService', () => {
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
})
it('resolves the deployment policy for an agentless call', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
})
})
it('resolves each session mode and cwd together without changing the fallback', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
const first = session('sess-first', '/projects/first')
const second = session('sess-second', '/projects/second')
setSandboxMode(second, 'read-only')
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/projects/first'),
})
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
mode: 'read-only',
workspaceRoot: resolve('/projects/second'),
})
expect(ctx.sandboxPolicy.resolve()).toEqual({
mode: 'workspace-write',
workspaceRoot: resolve('/fallback'),
})
})
it('resolves a symlink-sensitive session cwd with filesystem semantics', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-'))
try {
const lexical = join(root, 'lexical')
const physical = join(root, 'physical')
const child = join(physical, 'child')
mkdirSync(lexical)
mkdirSync(child, { recursive: true })
const link = join(lexical, 'link')
symlinkSync(child, link, process.platform === 'win32' ? 'junction' : 'dir')
const cwd = `${link}${sep}..`
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
mode: 'workspace-write',
workspaceRoot: realpathSync.native(physical),
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('lets an approved mode outrank the session mode while retaining its root', async () => {
const ctx = await mounted({ workspaceRoot: '/fallback' })
const active = session('sess-approved', '/projects/approved')
setSandboxMode(active, 'read-only')
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
mode: 'danger-full-access',
workspaceRoot: resolve('/projects/approved'),
})
})
it('uses the configured root when a session has no cwd', async () => {
const ctx = await mounted({ workspaceRoot: '/fallback' })
expect(ctx.sandboxPolicy.resolve({ session: session('sess-no-cwd') }).workspaceRoot).toBe(resolve('/fallback'))
})
it('rejects a mode outside the closed vocabulary at load', async () => {
const ctx = new Context()
// schemastery rejects the union violation when the plugin loads.

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-sandbox
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names the filesystem-canonical real host directory. Workspace identity is resolved before lexical normalization, so a valid cwd containing `symlink/..` grants the directory where `chdir` actually lands rather than an unrelated lexical parent. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/) (wraps `['bash', '-c', command]`).

View File

@@ -30,6 +30,18 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */
export type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
/**
* The complete file-effect policy resolved for one capability call. The root
* is carried even under modes that do not consume it so callers can resolve
* policy once before choosing the enforcement path.
*/
export interface SandboxExecutionPolicy {
/** The file-effect mode this execution runs under. */
mode: SandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
}
/**
* Enforcement completeness for this host. `partial` means an active backend or
* older kernel ABI cannot govern every promised file effect; callers requiring
@@ -42,15 +54,12 @@ export type SandboxEnforcement = 'full' | 'partial'
* fixed on the provider: two consumers may confine under different policies
* at the same instant (bash under `read-only` while a confined child agent
* needs its state directory writable), and an approved escalated retry is a
* new call with a wider policy. Defaulting/resolution is the consumer's
* explicit step (its config owns the fallback chain); the provider treats
* the policy as fully specified.
* new call with a wider policy. Defaulting/resolution is an explicit step at
* the consumer boundary; the provider treats the policy as fully specified.
*/
export interface SandboxPolicy {
export interface SandboxPolicy extends SandboxExecutionPolicy {
/** The file-effect mode this execution runs under. */
mode: ConfinedSandboxMode
/** Absolute root directory `workspace-write` may write under. */
workspaceRoot: string
}
/**

View File

@@ -15,7 +15,7 @@
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type { SandboxPolicy } from './index.ts'
import type { SandboxExecutionPolicy } from './index.ts'
/**
* Resolve a granted root to the path the enforcement layer actually compares:
@@ -29,9 +29,13 @@ import type { SandboxPolicy } from './index.ts'
*/
export function canonicalPath(path: string): string {
try {
return realpathSync(path)
// Node's JavaScript realpath implementation lexically collapses `..`
// before resolving a preceding symlink on some platforms. The native
// implementation follows the filesystem's component-by-component lookup,
// matching chdir/spawn and the enforcement layers this identity feeds.
return realpathSync.native(path)
} catch {
// realpathSync failed: the path (or a prefix) is missing or unreadable.
// realpathSync.native failed: the path (or a prefix) is missing or unreadable.
return path
}
}
@@ -45,7 +49,7 @@ export function canonicalPath(path: string): string {
* @param policy - the file-effect policy to derive the allow-list from.
* @returns the canonical writable roots; empty exactly under `read-only`.
*/
export function writableRoots(policy: SandboxPolicy): string[] {
export function writableRoots(policy: SandboxExecutionPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
}

View File

@@ -125,6 +125,11 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId):
if (legacy !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`)
}
const legacyModeType: string = 'mode/set'
const legacyMode = events.find(event => event.type === legacyModeType)
if (legacyMode !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`)
}
const fallback = events.find(event => event.type === 'request/header'
&& (event.data as { reason?: string }).reason === 'fallback')
if (fallback !== undefined) {

View File

@@ -22,6 +22,16 @@ function legacyHeaderDelta(seq = 0): SessionEvent {
} as unknown as SessionEvent
}
/** An unsupported named-mode fixture emulating an untyped producer. */
function legacyModeSet(seq = 0): SessionEvent {
return {
type: 'mode/set',
seq,
time: 1,
data: { mode: 'plan' },
} as unknown as SessionEvent
}
/** An obsolete full-header reason fixture from the removed delta codec. */
function legacyFallbackHeader(seq = 0): SessionEvent {
return {
@@ -510,6 +520,19 @@ describe('SessionPersistence service registration', () => {
await fiber.dispose()
})
it('rejects a stored legacy named-mode event during load', async () => {
const id = SessionId('legacy-mode-load')
const m = meta(id, '/legacy')
const store: MemoryStore = new Map([[id, { meta: m, events: [legacyModeSet()] }]])
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence, { store })
await expect(ctx.sessionPersistence.load(id))
.rejects.toThrow('unsupported legacy mode/set event at seq 0')
await fiber.dispose()
})
it('retires all coordinator bookkeeping for disposed sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -391,6 +391,9 @@ describe('LocalSkillProvider', () => {
await empty.plugin(SkillService)
SkillLocal.apply(empty, {})
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME

View File

@@ -4,8 +4,8 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie
Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
@@ -36,7 +36,7 @@ defineAcpSnapshotSuite({
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `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`, which fixes the length of both sidecar sequences.
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `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`, which fixes the length of both sidecar sequences.
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 whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere.

Some files were not shown because too many files have changed in this diff Show More