Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location
# Conflicts: # docs/module-graph.md # docs/tool-catalog.md # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # packages/bash/tool-bash/README.md # packages/bash/tool-bash/package.json # packages/bash/tool-bash/src/index.ts # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
+5
-3
@@ -1,10 +1,10 @@
|
||||
# Packages
|
||||
|
||||
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin, declares its ctx key/events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) (subtree) and the root [AGENTS.md](../AGENTS.md) § Conventions.
|
||||
Harness packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis plugin: a default `Service` subclass or functional plugin declaring ctx keys/events through declaration merging and contributing through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) and root [AGENTS.md](../AGENTS.md) § Conventions.
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -12,7 +12,9 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`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 |
|
||||
| [`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, and the model-facing file tools | 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 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface |
|
||||
@@ -23,7 +25,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# bash/ — bash capability family
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages.
|
||||
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.
|
||||
The interface lives at `bash/bash/`. `bash-sandbox` replacing `bash-local` without touching the interface or the tool is the split doing exactly what it exists for — a leaf `cordis.yml` picks one executor entry, plus a `ctx.sandbox` provider entry for the confined one (see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/)).
|
||||
@@ -27,4 +27,4 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi;
|
||||
|
||||
## Sandboxing
|
||||
|
||||
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § Extending The Harness. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
Execution policy does NOT belong in this package: this executor always runs commands unconfined. Confinement is [`dsh-bash-sandbox`](../bash-sandbox/README.md), which extends this executor verbatim and confines commands under the `ctx.sandbox` seam's bwrap/Landlock/Seatbelt backends ([sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); per-call allow/deny/ask policy belongs on the `tools/pre-execute` gate.
|
||||
@@ -133,6 +133,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
// Carry a sandbox-mode override 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +216,18 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full collected stderr of a tracked task from stream start (bounded by the
|
||||
* in-memory cap; bytes only in the spill file are not re-read). A protected
|
||||
* seam for subclasses that classify a settled task's outcome — reading here
|
||||
* does NOT advance the consumer's {@link readOutput} cursor. An unknown id
|
||||
* (a task already dropped by disposal) reads as empty.
|
||||
*/
|
||||
protected collectedStderr(id: BashTaskId): string {
|
||||
const task = this.tasks.get(id)
|
||||
return task === undefined ? '' : task.running.stderr.readFrom(0).text
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — the model-facing tool layer (`dsh-tool-bash`) is untouched; that swap is exactly what the seams exist for.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
|
||||
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
|
||||
| `danger-full-access` | No confinement; the provider is never consulted. Execution is `dsh-bash-local`'s verbatim — foreground results still carry `sandbox: { mode, denied: false }` (no `enforcement`: nothing was confined), background tasks carry no sandbox facts |
|
||||
|
||||
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 task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker.
|
||||
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it 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 RFC § Escalation](../../../docs/rfc/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.
|
||||
- **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 tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
|
||||
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
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 [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable demo.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* `SandboxBashExecutor`: the sandbox-consuming implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Every spawned command is wrapped by
|
||||
* the `ctx.sandbox` provider (`@deepseek-ai/dsh-sandbox`) according to the
|
||||
* configured {@link SandboxMode}: the executor hands the provider the exact
|
||||
* `['bash', '-c', command]` argv it is about to spawn and spawns the wrapped
|
||||
* argv instead. WHICH platform runner confines it — and whether one is
|
||||
* usable at all (the provider fails CLOSED with a structured
|
||||
* `SANDBOX_UNAVAILABLE` error rather than passing the argv through) — is the
|
||||
* provider's concern (`@deepseek-ai/dsh-sandbox-local` first).
|
||||
*
|
||||
* Extends `LocalBashExecutor` so all process mechanics — spawn, process-group
|
||||
* kills, timeout escalation, output collection and spill files, background
|
||||
* tasks, the credential scrub — are the local implementation's, verbatim.
|
||||
* This package adds only the seam consumption and the result facts, which is
|
||||
* exactly the split the capability seam was designed for (a sandboxing
|
||||
* executor replaces `dsh-bash-local` without touching `dsh-tool-bash`, and
|
||||
* swapping the confinement backend never touches this package).
|
||||
*
|
||||
* A failed run whose stderr carries the selected backend's own denial
|
||||
* dialect (the signatures the provider stamps on every wrap) is classified
|
||||
* as a sandbox denial on `BashRunResult.sandbox`, and every confined result
|
||||
* also carries how completely the selected runner enforces the mode
|
||||
* (`sandbox.enforcement`, from the provider's wrap). A failure carrying the
|
||||
* backend's RUNNER-FAILURE signature instead means the sandbox itself broke
|
||||
* and the command never ran: the foreground path re-throws it as the
|
||||
* structured fail-closed `SANDBOX_UNAVAILABLE` error (late twin of the
|
||||
* provider's confine-time throw), a settled background task stamps
|
||||
* `sandbox.runnerFailed` — either way a broken sandbox can never read as a
|
||||
* failing command, and the command never slips through unconfined.
|
||||
*
|
||||
* Deny-only at the seam, escalation at the tool: a denial is a reported FACT
|
||||
* here, and the one-shot user-approved escalated retry of a denied action
|
||||
* (docs/rfc/implemented/feature/2026-07-06-sandbox.md) is driven by
|
||||
* `dsh-tool-bash` through `ctx.approval` — this executor's contribution is the
|
||||
* per-call `sandboxMode` override it honors in {@link resolve}: an escalated
|
||||
* call runs (and classifies, and reports) under ITS granted mode while every
|
||||
* neighboring call keeps its session's standing mode (or the configured
|
||||
* default when that session has no override).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is NOT configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
/** File-sandbox mode commands run under (default: `read-only`). */
|
||||
mode?: SandboxMode
|
||||
/**
|
||||
* Root directory `workspace-write` mode may write under (default: the
|
||||
* executor's default working directory — `cwd`, else `process.cwd()`).
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word (embedded single
|
||||
* quotes become `'\''`), so a wrapped argv element survives the outer
|
||||
* `bash -c` re-parse byte-for-byte.
|
||||
* @param text - the raw argv element to quote.
|
||||
* @returns the single-quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative sandbox-denial classifier: a run counts as denied only when it
|
||||
* FAILED (nonzero exit — a signal kill is not a denial) and its stderr
|
||||
* carries one of the SELECTED BACKEND's own denial signatures — the dialect
|
||||
* the provider stamps on every wrap (`ConfinedArgv.denialSignatures`:
|
||||
* `Read-only file system` under bwrap's EROFS mounts, `Permission denied`
|
||||
* under Landlock's EACCES, `Operation not permitted` under Seatbelt's
|
||||
* EPERM). Matching the backend's dialect rather than a cross-backend union
|
||||
* keeps the classifier from claiming denials the active backend never
|
||||
* produces (bare EPERM text under a Linux runner names non-file boundaries —
|
||||
* mount, kill, ptrace — that fail the same way unsandboxed). Text inference
|
||||
* is the fallback signal until a runner provides a structured one (which
|
||||
* wins once it exists); it errs toward NOT claiming a denial, and its known
|
||||
* residual imprecision is non-sandbox text in the active dialect (an ssh
|
||||
* auth failure reads as a denial under Landlock, a refused `kill` under
|
||||
* Seatbelt).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's denial dialect, case-insensitive
|
||||
* stderr substrings.
|
||||
* @returns whether the run's failure reads as a sandbox denial.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-failure classifier: a failed run whose stderr carries the SELECTED
|
||||
* BACKEND's own runner-failure signature (`ConfinedArgv.
|
||||
* runnerFailureSignatures`: the runner's error prefix, which also matches
|
||||
* the shell's runner-not-found message) means the SANDBOX itself failed and
|
||||
* the command never ran. Checked BEFORE {@link classifyDenial} — a runner's
|
||||
* error text can contain denial words (an unopenable grant root reports
|
||||
* `Permission denied`) — and surfaced as the fail-closed
|
||||
* `SANDBOX_UNAVAILABLE` error on the foreground path, `sandbox.runnerFailed`
|
||||
* on a settled background task. Same conservative-text-inference stance and
|
||||
* residual imprecision as the denial classifier (a failing task that itself
|
||||
* prints the runner's prefix reads as a runner failure).
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's runner-failure signatures,
|
||||
* case-insensitive stderr substrings.
|
||||
* @returns whether the run's failure reads as the runner itself failing.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* The classifier core shared by foreground results and settled background
|
||||
* tasks: failed AND signature present. Lowercases BOTH sides — the seam
|
||||
* declares its signatures case-insensitive, and producers compose them from
|
||||
* runtime data of any case (an `argv0` path, `No such file or directory`).
|
||||
*/
|
||||
function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-consuming bash executor. Registers as `ctx.bash` (loading it
|
||||
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
|
||||
* the whole swap — the tool layer is untouched). Its configured mode is the
|
||||
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
|
||||
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
|
||||
* request, while an approved escalation may stamp a strictly wider mode for
|
||||
* one call. The tool's per-agent prompt section states that same effective
|
||||
* mode, and each run's `result.sandbox` reports what actually executed plus
|
||||
* enforcement completeness.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox']
|
||||
|
||||
// The sandbox-specific fields intersect the local executor's Config as an
|
||||
// inline schema call: the config catalog walks `static Config` statically.
|
||||
static override Config: z<Config> = z.intersect([
|
||||
LocalBashExecutor.Config,
|
||||
z.object({
|
||||
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
||||
workspaceRoot: z.string(),
|
||||
}),
|
||||
])
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-task facts, keyed by task id from `start()` until the settle stamp
|
||||
* consumes them: the mode the task runs under (per-call — an escalated task
|
||||
* differs from its neighbors) plus its wrap facts. The seam returns facts
|
||||
* PER WRAP — a provider may legally vary enforcement or dialect between
|
||||
* calls — so overlapping background tasks must each classify against their
|
||||
* OWN wrap; a single latest-wrap field would let a later `start()` clobber
|
||||
* an earlier task's facts before it settles. A `danger-full-access` task
|
||||
* has NO entry (nothing confined it), which is what the settle stamp keys
|
||||
* off.
|
||||
*/
|
||||
private readonly taskFacts = new Map<BashTaskId, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// schemastery (static Config) already filled the defaulted fields — the
|
||||
// cast records that runtime fact (mirrors LocalBashExecutor's config
|
||||
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
|
||||
// fallback chain is real branching.
|
||||
this.mode = config.mode as SandboxMode
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
|
||||
}
|
||||
|
||||
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
|
||||
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 result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial: the sandbox itself failed and the
|
||||
// command NEVER RAN — surface the same structured fail-closed error a
|
||||
// confine-time discovery throws (late detection, same outcome), with
|
||||
// the runner's own first stderr line as the cause. Returning it as a
|
||||
// task result would let a broken sandbox read as a failing command.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashTask {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Sandbox facts are stamped at settle time by {@link notifyTaskDone}
|
||||
// (denial classification runs against the settled task's collected
|
||||
// stderr). The map entry lands synchronously after spawn, strictly
|
||||
// before the earliest possible settle (a process exit reaches us no
|
||||
// sooner than the next tick).
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const task = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the sandbox facts BEFORE completion listeners run: the base
|
||||
* executor notifies from inside the task's settle path, so overriding the
|
||||
* notification point is what makes `task.sandbox` visible to `onTaskDone`
|
||||
* consumers and `done` awaiters alike. Each task classifies against the
|
||||
* facts of ITS OWN wrap and reports ITS OWN mode (consumed from the
|
||||
* per-task map here — settle is the entry's end of life): with per-call
|
||||
* escalation, tasks under different modes settle side by side, so keying
|
||||
* anything off the configured default would misreport them. A
|
||||
* `danger-full-access` task has no map entry and carries no facts (nothing
|
||||
* confined it); a signal-killed task (null exit code) is never a denial,
|
||||
* mirroring the foreground classifier.
|
||||
*/
|
||||
protected override notifyTaskDone(task: BashTask): void {
|
||||
const facts = this.taskFacts.get(task.id)
|
||||
if (facts !== undefined) {
|
||||
this.taskFacts.delete(task.id)
|
||||
const stderr = this.collectedStderr(task.id)
|
||||
// Runner failure outranks denial (the command never ran; the runner's
|
||||
// own error text can contain denial words). A settled task has no
|
||||
// error channel left, so the fact IS the surface here — the foreground
|
||||
// path throws instead.
|
||||
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
task.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.notifyTaskDone(task)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
|
||||
* `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): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxBashExecutor
|
||||
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof under bwrap: the REAL
|
||||
* `LocalSandboxProvider` (nothing forced — bwrap is the ladder's first rung,
|
||||
* so a passing probe selects it) underneath the REAL `SandboxBashExecutor`,
|
||||
* driven through the executor's public run/start paths. Verifies the WORLD
|
||||
* (files exist or don't) plus the stamped result facts — in particular that
|
||||
* bwrap's EROFS denial text classifies as `denied: true` through the
|
||||
* wrap-carried dialect; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
|
||||
* host that denies unprivileged user namespaces.
|
||||
*
|
||||
* HOME-based dirs on purpose: bwrap's `/tmp` is an ephemeral mount, so only
|
||||
* paths outside it prove the workspace-root boundary.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const bwrapUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
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' }))
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
|
||||
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
|
||||
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
|
||||
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
|
||||
* facts; the backend-only confinement proofs live with
|
||||
* `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock; the
|
||||
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
const landlockUsable = probe.status === 0
|
||||
/** The kernel's enforcement level from the probe report — stamped facts below must match it. */
|
||||
const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
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' }))
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* SandboxBashExecutor tests: the CONSUMER side of the sandbox seam. A fake
|
||||
* `ctx.sandbox` provider (injected as a real cordis service) makes wrapping,
|
||||
* policy hand-off, fail-closed propagation, classification, and fact
|
||||
* stamping all deterministic without any real runner; the real-provider
|
||||
* integration proof lives in `tests/landlock.e2e.ts`. Denials are produced
|
||||
* with plain unix permissions (a 0555 directory), which exercises the same
|
||||
* stderr signature the classifier keys on.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
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, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-runner: '] as const
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
* per test) and the executor under test on top of it.
|
||||
*/
|
||||
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
calls.push({ argv: [...argv], policy })
|
||||
return behavior(argv, policy)
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
}
|
||||
|
||||
function output(text: string): CollectedOutput {
|
||||
return { text, truncated: false }
|
||||
}
|
||||
|
||||
function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
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()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
|
||||
expect(result.stdout.text).toBe('a b c\'d\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(calls).toEqual([{
|
||||
argv: ['bash', '-c', 'echo \'a b\' "c\'d"'],
|
||||
policy: { mode: 'read-only', workspaceRoot: resolve(process.cwd()) },
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot wins over cwd', async () => {
|
||||
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
|
||||
})
|
||||
|
||||
it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
|
||||
const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
|
||||
const spec = bash.resolve({ command: 'echo hi' })
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('start() passes through unwrapped and stamps nothing at settle', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'danger-full-access' })
|
||||
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(bash.readOutput(task.id).delta).toContain('free-bg')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', 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' }))
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', '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' }))
|
||||
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' }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('overlapping background tasks settle with their OWN modes (an escalated task next to a default one)', async () => {
|
||||
// With per-call policy, tasks under different modes are in flight at
|
||||
// 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 plain = bash.start(bash.resolve({ command: 'true' }))
|
||||
await plain.done
|
||||
await escalated.done
|
||||
expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(plain.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
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' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(bash.readOutput(task.id).delta).toContain('bg-free')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDenial', () => {
|
||||
it('never classifies a clean exit or a signal kill as a denial', () => {
|
||||
expect(classifyDenial(runResult(0, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(null, 'Permission denied'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies failed runs by the wrap\'s own dialect, conservatively', () => {
|
||||
expect(classifyDenial(runResult(1, 'touch: cannot touch /x: Read-only file system'), UNIX_SIGNATURES)).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), UNIX_SIGNATURES)).toBe(true)
|
||||
// Bare EPERM is not a Linux runner's dialect: mount/kill/ptrace fail with
|
||||
// it unsandboxed too, and the mode vocabulary governs file effects only —
|
||||
// claiming a file denial here would tell the model the sandbox blocked
|
||||
// something it never governed.
|
||||
expect(classifyDenial(runResult(1, 'mount: Operation not permitted'), UNIX_SIGNATURES)).toBe(false)
|
||||
expect(classifyDenial(runResult(1, 'No such file or directory'), UNIX_SIGNATURES)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches exactly the active backend\'s dialect: EPERM classifies under Seatbelt, EACCES does not under bwrap', () => {
|
||||
// The same stderr flips meaning with the backend: under Seatbelt, EPERM
|
||||
// text IS how the kernel refuses a governed file write; under bwrap's
|
||||
// EROFS-only dialect, `Permission denied` is ordinary DAC, not the
|
||||
// sandbox — per-wrap signatures are what keep both classifications honest.
|
||||
expect(classifyDenial(runResult(1, 'bash: /etc/x: Operation not permitted'), ['operation not permitted'])).toBe(true)
|
||||
expect(classifyDenial(runResult(1, 'sh: /x: Permission denied'), ['read-only file system'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
|
||||
})
|
||||
|
||||
it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
|
||||
const { bash } = await setup()
|
||||
await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
|
||||
.rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
})
|
||||
|
||||
it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
|
||||
const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const seen: unknown[] = []
|
||||
ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
|
||||
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
|
||||
})
|
||||
|
||||
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
|
||||
// The seam returns facts PER WRAP — a legal provider may vary them
|
||||
// between calls. The slow task settles AFTER the quick one started, so a
|
||||
// latest-wrap field would classify its denial against the quick task's
|
||||
// dialect (missing it) and stamp the wrong enforcement.
|
||||
const wraps: Array<Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>> = [
|
||||
{ enforcement: 'partial', denialSignatures: ['permission denied'] },
|
||||
{ enforcement: 'full', denialSignatures: ['read-only file system'] },
|
||||
]
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
await quick.done
|
||||
await slow.done
|
||||
expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(quick.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('a signal-killed task is never a denial (null exit code)', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
|
||||
// Let the stderr land before the kill so the classifier sees the
|
||||
// signature and must still refuse it on the null exit code alone.
|
||||
await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
|
||||
bash.kill(task.id)
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('disposal kills wrapped background tasks (inherited HMR safety)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 30' }))
|
||||
await ctx.fiber.dispose()
|
||||
expect(task.status).toBe('killed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
* KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider`
|
||||
* (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath
|
||||
* the REAL `SandboxBashExecutor`, driven through the executor's public
|
||||
* run/start paths. Verifies the WORLD (files exist or don't) plus the
|
||||
* stamped result facts — in particular that Seatbelt's EPERM denial text
|
||||
* classifies as `denied: true` through the wrap-carried dialect; the
|
||||
* backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — every non-macOS host, or
|
||||
* a macOS whose `sandbox-exec` refuses the profile.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const seatbeltUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement through ctx.bash', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
// HOME-based dirs on purpose: workspace-write grants /tmp and the
|
||||
// per-user temp dir wholesale, so only paths outside both prove the
|
||||
// workspace-root boundary.
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
|
||||
expect(inside.exitCode).toBe(0)
|
||||
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
|
||||
|
||||
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const task = bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an approved escalated retry — the spec-level workspace-write override — lands the exact write read-only denied', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
const command = `printf escalated > ${workdir}/escalated.txt`
|
||||
const strict = await bash.run(bash.resolve({ command }))
|
||||
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' }))
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
|
||||
|
||||
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
|
||||
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts |
|
||||
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
|
||||
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
|
||||
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface, tool schemas untouched; a containerized or remote executor slots in the same way.
|
||||
|
||||
## Service API (`ctx.bash`)
|
||||
|
||||
@@ -19,6 +20,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
|
||||
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
|
||||
| `get(id)` / `list()` | Task lookup. |
|
||||
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
|
||||
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
|
||||
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
|
||||
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
|
||||
@@ -28,6 +30,8 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
|
||||
|
||||
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
@@ -23,10 +23,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,16 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
BashRunResult,
|
||||
BashSandboxInfo,
|
||||
BashTask,
|
||||
BashTaskListener,
|
||||
BashTaskRead,
|
||||
@@ -70,6 +73,22 @@ export abstract class BashExecutor extends Service {
|
||||
}, 'bash listener teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox mode this executor confines commands under BY DEFAULT, or
|
||||
* `undefined` when it does not sandbox at all — the capability fact the
|
||||
* tool and ACP layers read to advertise sandbox controls honestly. The
|
||||
* getter proves a sandboxing executor is mounted and supplies its fallback
|
||||
* mode; a session override may make the effective mode narrower or wider,
|
||||
* so strict escalation widening is checked per call rather than encoded in
|
||||
* this default-relative capability fact. The base class reports
|
||||
* `undefined`; a sandboxing implementation overrides the getter.
|
||||
* @returns the configured default mode of a sandboxing executor;
|
||||
* `undefined` for an executor that never confines.
|
||||
*/
|
||||
get sandboxMode(): SandboxMode | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a caller's {@link BashExecRequest} into a fully-specified
|
||||
* {@link BashExecSpec}, applying this implementation's config defaults and
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Per-session sandbox-mode override: the session log as the store. A runtime
|
||||
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
|
||||
* one `bash/sandbox-mode` event on the session it applies to;
|
||||
* `effective = fold(events) ?? the executor's configured default`, so an
|
||||
* override survives restart by replay, two sessions can 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
|
||||
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
|
||||
* never from the event itself. EXECUTION honors the fold in the tool layer —
|
||||
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
|
||||
* (weakest-precedence: an escalation grant for the call outranks it) — the
|
||||
* executor itself stays a config-fixed default plus per-call overrides.
|
||||
*
|
||||
* @module dsh-bash/session-mode
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's sandbox mode was switched — log-only (like `approval/*`;
|
||||
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
|
||||
* never in the model transcript. The LAST such event is the session's
|
||||
* override ({@link effectiveSandboxMode}); who asked for it is derivable
|
||||
* from position (an event after the log's last `request/header*` was a
|
||||
* runtime switch by the user; see the tool layer's narrator).
|
||||
*/
|
||||
'bash/sandbox-mode': { mode: SandboxMode }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
* executor's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the mode of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'bash/sandbox-mode') return event.data.mode
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's sandbox-mode override: appends exactly one
|
||||
* `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode
|
||||
* state out of band. Takes effect on the session's next bash call and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* @param session - the session the override belongs to.
|
||||
* @param mode - the mode every subsequent bash call in this session runs
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setSandboxMode(session: Session, mode: SandboxMode): void {
|
||||
session.append('bash/sandbox-mode', { mode })
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
@@ -40,6 +41,48 @@ export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox facts for one foreground run — present on {@link BashRunResult} iff
|
||||
* a sandboxing executor ran the command (an unsandboxed executor reports no
|
||||
* `sandbox` field at all). Reported independently of `exitCode`/`signal`
|
||||
* (orthogonal outcomes), so a caller can tell "the command failed on its own"
|
||||
* from "the sandbox blocked a file operation". The mode/enforcement
|
||||
* vocabulary lives on the `@deepseek-ai/dsh-sandbox` seam; this shape is the
|
||||
* bash seam's result-fact carrier for it.
|
||||
*/
|
||||
export interface BashSandboxInfo {
|
||||
/** The mode the command actually ran under. */
|
||||
mode: SandboxMode
|
||||
/**
|
||||
* True when the executor classifies this run's failure as the sandbox
|
||||
* denying a file operation. The classification is CONSERVATIVE (a failed
|
||||
* exit whose stderr carries a filesystem-permission signature) and reads
|
||||
* the COLLECTED stderr — the bounded in-memory tail per
|
||||
* {@link CollectedOutput} semantics, so a signature that survives only in a
|
||||
* spill file is missed toward `denied: false`. A plain command failure
|
||||
* keeps `denied: false` even under a sandboxed mode.
|
||||
*/
|
||||
denied: boolean
|
||||
/**
|
||||
* How completely the runner enforced `mode`'s file effects — see
|
||||
* {@link SandboxEnforcement}. Absent exactly when `mode` is
|
||||
* `danger-full-access`: nothing is confined, so there is no enforcement to
|
||||
* report.
|
||||
*/
|
||||
enforcement?: SandboxEnforcement
|
||||
/**
|
||||
* True when the executor classifies this failure as the SANDBOX RUNNER
|
||||
* itself failing (missing binary, refused profile, fail-closed refusal
|
||||
* before exec) — the command NEVER RAN; this is a sandbox failure, not a
|
||||
* task failure, and it outranks `denied` (a runner's own error text can
|
||||
* contain denial words). Only ever stamped on settled BACKGROUND tasks: a
|
||||
* foreground run surfaces the same condition as the thrown
|
||||
* `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error
|
||||
* channel; a settled task's facts are its only channel).
|
||||
*/
|
||||
runnerFailed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
|
||||
* filled by {@link BashExecutor.resolve} from the implementation's config.
|
||||
@@ -81,6 +124,20 @@ export interface BashExecRequest {
|
||||
* ownerless background start (a non-agent caller).
|
||||
*/
|
||||
owner?: OwnerToken | undefined
|
||||
/**
|
||||
* Explicit per-call sandbox-policy input, overriding the executor's
|
||||
* configured default mode for THIS call. Never a silent default: a
|
||||
* consumer sets it only from an explicit policy source — an
|
||||
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
|
||||
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
|
||||
* session's standing override folded from its own `bash/sandbox-mode`
|
||||
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
|
||||
* choice). A sandboxing executor confines THIS call under the given mode;
|
||||
* a non-sandboxing executor carries the field and confines nothing (the
|
||||
* tool layer stamps neither escalation nor overrides without a sandboxing
|
||||
* executor — see {@link BashExecutor.sandboxMode}).
|
||||
*/
|
||||
sandboxMode?: SandboxMode | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +179,16 @@ export interface BashExecSpec {
|
||||
* task. `start()` stores it; `run()` (foreground) ignores it.
|
||||
*/
|
||||
owner: OwnerToken | undefined
|
||||
/**
|
||||
* The sandbox mode this call executes under, REQUIRED-but-nullable for the
|
||||
* same visibility reason as `owner`. A sandboxing executor's `resolve()`
|
||||
* stamps the effective mode (the request's explicit override, else its
|
||||
* configured default) so `run()`/`start()` read the spec, never the config;
|
||||
* a non-sandboxing executor carries the request value through verbatim and
|
||||
* ignores it (`undefined` under such an executor means what its README says:
|
||||
* unconfined execution).
|
||||
*/
|
||||
sandboxMode: SandboxMode | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
@@ -148,6 +215,12 @@ export interface BashRunResult {
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
/**
|
||||
* Sandbox facts, present iff a sandboxing executor ran the command — an
|
||||
* unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See
|
||||
* {@link BashSandboxInfo} for the `denied` classification semantics.
|
||||
*/
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/** Lifecycle of a background task. */
|
||||
@@ -164,6 +237,16 @@ export interface BashTask {
|
||||
signal: NodeJS.Signals | null
|
||||
/** Resolves when the underlying process closes (never rejects). */
|
||||
readonly done: Promise<void>
|
||||
/**
|
||||
* Sandbox facts for this task's execution, stamped by a sandboxing executor
|
||||
* once the task settles and BEFORE completion listeners are notified — an
|
||||
* `onTaskDone` consumer and a `done` awaiter both see it. Denial
|
||||
* classification runs against the settled task's collected stderr, so the
|
||||
* field cannot exist earlier: absent while the task is running and under an
|
||||
* executor that does not sandbox. See {@link BashSandboxInfo} for the
|
||||
* `denied` semantics.
|
||||
*/
|
||||
sandbox?: BashSandboxInfo
|
||||
}
|
||||
|
||||
/** One incremental {@link BashExecutor.readOutput} read. */
|
||||
|
||||
@@ -15,6 +15,7 @@ class StubExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +97,11 @@ describe('BashExecutor service seam', () => {
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('reports no default sandbox mode (composition truth: the base never confines)', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onTaskDone delivers completions to registered listeners', async () => {
|
||||
const { bash } = await setup()
|
||||
const seen: string[] = []
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,7 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility).
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -17,6 +17,8 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
|
||||
| `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. |
|
||||
| `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()`.
|
||||
|
||||
@@ -26,11 +28,11 @@ Every foreground and background call made for an agent receives `DSH_SESSION_ID=
|
||||
|
||||
The overlay is computed from `ToolExecution.agent` for each call and passed through `BashExecRequest.env`; `process.env` is never modified, so concurrent parent/child agents keep separate values. The tool description names both variables so the model can inspect them without a permanent system-prompt section.
|
||||
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
Result text: stdout, then a `[stderr]` section, then status markers — `[sandbox: file access denied under <mode> mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
|
||||
|
||||
### `bash_output`
|
||||
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). A settled task classified as a sandbox denial carries the same `[sandbox: file access denied under <mode> mode]` marker on every read that sees it (denials are only classifiable once the whole stderr has been collected). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
@@ -52,6 +54,12 @@ When a background task finishes, a short notice is injected into the owning agen
|
||||
|
||||
The `BashExecRequest` seam carries optional `stdin` and `env`, used by trusted consumers. This tool does **not** expose them as model parameters: it builds the request from named schema fields and adds only the session overlay above, so model-supplied `env`/`stdin` keys are ignored and cannot replace the trusted values. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking ambient secrets is `dsh-bash-local`'s credential scrub. Regression guards assert extra model fields never enter the request while the trusted overlay still does. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Permissions
|
||||
## Permissions and escalation
|
||||
|
||||
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
|
||||
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
|
||||
|
||||
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../ui/user-approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
|
||||
|
||||
## Per-session mode switching
|
||||
|
||||
Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state.
|
||||
@@ -23,9 +23,11 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -33,9 +35,13 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
@@ -30,10 +30,27 @@
|
||||
* completion landing during the reload gap still drops its one notice — the
|
||||
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
*
|
||||
* TODO(permissions): commands run with the executor's full authority. The
|
||||
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
|
||||
* sandboxing `BashExecutor` implementations — see docs/architecture.md
|
||||
* § Extending The Harness.
|
||||
* Commands run with the executor's full authority unless a sandboxing
|
||||
* executor (`@deepseek-ai/dsh-bash-sandbox`) confines them; per-call
|
||||
* allow/deny/ask policy is the `tools/pre-execute` waterfall — see
|
||||
* docs/architecture.md § Extension And Composition. Under a sandboxing
|
||||
* executor this plugin also advertises the ESCALATION surface
|
||||
* (`sandbox_permissions`/`justification` — the sandbox RFC § Escalation,
|
||||
* docs/rfc/implemented/feature/2026-07-06-sandbox.md): a command the
|
||||
* sandbox denied may be retried once under a strictly wider mode, resolved
|
||||
* through `ctx.approval` BEFORE anything executes and failing closed on every
|
||||
* unanswerable path. The fields exist only when the mounted executor reports
|
||||
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
|
||||
* that the composition cannot honor.
|
||||
*
|
||||
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
|
||||
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
|
||||
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
|
||||
* call is stamped `escalation grant > session override > executor default`.
|
||||
* The prompt deliberately does NOT state the mode and no switch is narrated:
|
||||
* the model learns the boundary from the denial marker (which names the mode
|
||||
* it ran under) exactly when it matters, instead of preemptively refusing
|
||||
* work a standing declaration would discourage.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
@@ -41,11 +58,17 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
// Side-effect type import: declaration-merges `ctx.approval`, consumed
|
||||
// opportunistically by the escalation gate (`ctx.get('approval')` — the seam
|
||||
// stays optional at runtime, same pattern as dsh-tools' ask routing).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
@@ -56,16 +79,12 @@ export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
* arg-validation RFC), so type/required/enum checks are already done and `args`
|
||||
* is the validated `InferArgs` shape here. What remains are value constraints
|
||||
* the DSL has no vocabulary for: non-empty strings and a positive, finite
|
||||
* timeout.
|
||||
* the DSL has no vocabulary for: non-empty strings, a positive finite timeout,
|
||||
* and the escalation pairing (`sandbox_permissions` and `justification` travel
|
||||
* together — an approval prompt without a reason, or a reason driving nothing,
|
||||
* is a malformed ask).
|
||||
*/
|
||||
function validateBashArgs(args: {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
}): void {
|
||||
function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
@@ -75,6 +94,15 @@ function validateBashArgs(args: {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
if (args.sandbox_permissions !== undefined && args.justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (args.justification !== undefined && args.sandbox_permissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (args.justification !== undefined && args.justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +117,77 @@ function validateTaskId(value: string): BashTaskId {
|
||||
return BashTaskId(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bash tool's validated argument shape — the base parameters plus the two
|
||||
* escalation fields, which are ADVERTISED only when the mounted executor
|
||||
* reports a confining default mode (absent from the schema otherwise, so the
|
||||
* SchemaSpec validator rejects them before `execute` ever sees one).
|
||||
*/
|
||||
interface BashToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into the schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted executor confines: cutting the enum down to the modes
|
||||
* wider than the executor's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
*/
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The bash tool's static description. The base text is byte-stable regardless
|
||||
* of composition (it is part of the pinned snapshot header); the escalation
|
||||
* teaching rides only when the mounted executor actually honors the fields —
|
||||
* it names the ONE sanctioned exception to the base text's "do not retry
|
||||
* another way" rule. Its deference clause ("If the session states approval
|
||||
* prompts are disabled…") points at the approval plugin's never-policy prompt
|
||||
* sentence by meaning, not by parsed wording — a rendezvous kept working by
|
||||
* that sentence continuing to open with the approvals-disabled claim.
|
||||
*/
|
||||
function bashDescription(escalationModes: readonly SandboxMode[]): string {
|
||||
const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, '
|
||||
+ '`$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. '
|
||||
+ 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.'
|
||||
if (escalationModes.length === 0) return base
|
||||
return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
|
||||
+ 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it '
|
||||
+ 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry '
|
||||
+ 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
|
||||
+ 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
|
||||
+ 'approval prompt raised by that retry IS how the user consents. If the session states approval '
|
||||
+ 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
|
||||
+ 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command '
|
||||
+ 'just hit; escalating up front is fine only when this session already denied the same access. '
|
||||
+ 'A rejected escalation is final for THAT command — stop and explain, never work around '
|
||||
+ 'it — but it does not forbid attempting or escalating other commands later.'
|
||||
}
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
@@ -101,9 +200,15 @@ function streamText(output: CollectedOutput): string {
|
||||
* errored — the model decides how to react; only infrastructure failures
|
||||
* (spawn errors, aborts) surface as isError results.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @param escalationModes - the escalation targets this composition advertises;
|
||||
* non-empty adds the same-turn escalation hint after a denial marker
|
||||
* (default `[]`: no hint).
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderResult(result: BashRunResult): string {
|
||||
export function renderResult(
|
||||
result: BashRunResult,
|
||||
escalationModes: readonly SandboxMode[] = [],
|
||||
): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
@@ -116,6 +221,19 @@ export function renderResult(result: BashRunResult): string {
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
|
||||
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
|
||||
// reported fact like timeout: the model decides how to react.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
// The same-turn nudge lives at the decision point: only when this
|
||||
// composition advertises the fields (a lever is never hinted that the
|
||||
// schema does not offer), and inside the sandbox marker family so the
|
||||
// exit-code marker stays the last line.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
}
|
||||
}
|
||||
// Timeout is reported independently of how the process actually ended: a
|
||||
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
|
||||
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
|
||||
@@ -372,16 +490,87 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
})
|
||||
|
||||
// The escalation surface exists whenever the mounted executor confines.
|
||||
// Its enum is the closed target vocabulary, deliberately NOT cut down by
|
||||
// the configured default: a session may switch to a narrower effective mode
|
||||
// while sharing this globally registered schema. Strict widening therefore
|
||||
// belongs to the per-call check below. An executor swap restarts this fiber
|
||||
// (static inject) and re-registers the schema.
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
|
||||
* onto the request so EXECUTION follows the same effective mode the prompt
|
||||
* section states. Weakest precedence — an escalation grant (freshly
|
||||
* approved for exactly this call) outranks it, and without either the
|
||||
* executor's `resolve()` applies its configured default. Undefined for a
|
||||
* non-sandboxing executor (nothing honors it) and for agent-less callers
|
||||
* (no session to fold).
|
||||
*/
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes. Returns the granted mode to stamp onto the bash
|
||||
* request; throws the distinct fail-closed text for every other path (no
|
||||
* service composed, an agent-less execution, a rejection, a cancellation,
|
||||
* an unanswerable ask) — the registry turns the throw into this call's
|
||||
* isError result, and nothing has run. The seam is consumed
|
||||
* opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a
|
||||
* deployment without it degrades per call, never at registration.
|
||||
*/
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
// Schema validation only checks ADVERTISED keys, so an unadvertised
|
||||
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
|
||||
// human is never prompted to "escalate" a sandbox that is not there. When
|
||||
// the fields ARE advertised, the registry's SchemaSpec enum has already
|
||||
// pinned `mode` to this ladder for every caller.
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening is an EXECUTION check against the call's effective
|
||||
// mode — session override ?? executor default, the same fold ordinary
|
||||
// calls are stamped with — deliberately not a schema constraint (the
|
||||
// enum is the closed target vocabulary; the effective mode is per-call
|
||||
// truth). A non-widening request fails closed here and never prompts a
|
||||
// human.
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
// Self-contained for the audit trail: approval/asked stores this
|
||||
// reason, and the target mode is part of the grant's identity.
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The SchemaSpec enum already pinned `mode` to the closed target
|
||||
// vocabulary; the per-call check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'The current agent session id is available as `$DSH_SESSION_ID`; when JSONL persistence is configured, '
|
||||
+ '`$DSH_SESSION_JSONL` is its absolute target path and may not exist or contain the current unflushed turn yet. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
|
||||
+ 'poll it with `bash_output` and stop it with `bash_kill`.',
|
||||
description: bashDescription(escalationModes),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The bash command to execute.' },
|
||||
description: {
|
||||
@@ -394,12 +583,33 @@ export function apply(ctx: Context): void {
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
|
||||
...escalationModes.length > 0 ? {
|
||||
sandbox_permissions: {
|
||||
type: 'string' as const,
|
||||
enum: [...escalationModes],
|
||||
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry '
|
||||
+ 'of a command the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string' as const,
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
||||
+ 'why this exact command needs the wider access.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
async execute(args: BashToolArgs, exec) {
|
||||
validateBashArgs(args)
|
||||
// `description` is display/logging metadata only (surfaced to UIs via
|
||||
// the tool/call session event); it is intentionally NOT forwarded to
|
||||
// ctx.bash and has no effect on execution.
|
||||
// An escalating call resolves approval BEFORE anything executes; every
|
||||
// non-grant outcome throws its distinct error text and runs nothing.
|
||||
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
// Default the workdir to the calling agent's session cwd so each ACP
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
// model workdir still wins.
|
||||
@@ -411,6 +621,7 @@ export function apply(ctx: Context): void {
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...env !== undefined ? { env } : {},
|
||||
...sandboxMode !== undefined ? { sandboxMode } : {},
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Stamp the owner token (the agent's session id) onto the spec so the
|
||||
@@ -422,7 +633,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve(request))
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result) }]
|
||||
return [{ type: 'text', text: renderResult(result, escalationModes) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
@@ -449,6 +660,21 @@ export function apply(ctx: Context): void {
|
||||
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
|
||||
}
|
||||
text += `\n${statusLine(read.task)}`
|
||||
if (read.task.sandbox?.runnerFailed) {
|
||||
// The sandbox RUNNER itself failed — the command never ran. The
|
||||
// foreground path surfaces this as the structured SANDBOX_UNAVAILABLE
|
||||
// error; a settled task's read carries the marker instead.
|
||||
text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`
|
||||
} else if (read.task.sandbox?.denied) {
|
||||
// Mirrors the foreground result marker (and its same-turn escalation
|
||||
// hint). Background denials are only classifiable once the task
|
||||
// settles (the classifier needs the whole stderr), so the marker
|
||||
// rides every read that sees the settled task.
|
||||
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
|
||||
if (escalationModes.length > 0) {
|
||||
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
|
||||
}
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -13,11 +14,29 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
|
||||
|
||||
// Pure-config passthrough runner (same knob the snapshot tier uses): skips the
|
||||
// profile args up to `--` and execs the command unconfined — deterministic
|
||||
// without a host bwrap.
|
||||
const PASSTHROUGH_RUNNER = ['bash', '-c', 'while [ "$1" != "--" ]; do shift; done; shift; exec "$@"', 'passthrough-runner']
|
||||
const PASSTHROUGH_RUNNER_CONFIG = {
|
||||
runnerCommand: PASSTHROUGH_RUNNER,
|
||||
// The script has no pre-exec failure path; the provider still requires an
|
||||
// explicit dialect so a future script change cannot silently turn runner
|
||||
// failure into an ordinary command result.
|
||||
runnerFailureSignatures: ['passthrough-runner: profile rejected'],
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -102,6 +121,7 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +916,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 } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
run(): Promise<BashRunResult> {
|
||||
@@ -1067,3 +1088,512 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
expect('owner' in request).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox rendering', () => {
|
||||
const sandboxResult = (denied: boolean, exitCode: number): BashRunResult => ({
|
||||
exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: denied ? 'bash: /x: Read-only file system' : 'boom', truncated: false },
|
||||
sandbox: { mode: 'read-only', denied },
|
||||
})
|
||||
|
||||
it('renders a denial marker BEFORE the exit-code marker (the $-anchored parse survives)', () => {
|
||||
const text = renderResult(sandboxResult(true, 1))
|
||||
expect(text).toMatch(/\[sandbox: file access denied under read-only mode\]\n\[exit code: 1\]$/)
|
||||
})
|
||||
|
||||
it('appends the same-turn escalation hint to a denial exactly when the fields are advertised', () => {
|
||||
const hinted = renderResult(sandboxResult(true, 1), ['workspace-write', 'danger-full-access'])
|
||||
expect(hinted).toMatch(
|
||||
/denied under read-only mode\]\n\[sandbox: escalation available — retry this exact command once with sandbox_permissions [^\n]+\]\n\[exit code: 1\]$/, // eslint-disable-line @stylistic/max-len -- the hint sentence is pinned verbatim
|
||||
)
|
||||
// Default (no advertisement): no hint — a lever the schema does not offer is never suggested.
|
||||
expect(renderResult(sandboxResult(true, 1))).not.toContain('escalation available')
|
||||
// A non-denied result never hints, advertised or not.
|
||||
expect(renderResult(sandboxResult(false, 2), ['danger-full-access'])).not.toContain('escalation available')
|
||||
})
|
||||
|
||||
it('renders no sandbox marker for a plain failure under a sandboxed mode', () => {
|
||||
expect(renderResult(sandboxResult(false, 2))).not.toContain('[sandbox:')
|
||||
})
|
||||
|
||||
it('bash_output reports a settled background denial with the same marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const started = await call(ctx, 'bash', { command: 'echo "x: Permission denied" >&2; exit 1', description: 'test command', run_in_background: true })
|
||||
const id = text(started).match(/started background task (bash-\d+)/)![1]
|
||||
await bash.list().find(task => task.id === id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toMatch(
|
||||
/\[status: completed, exit code: 1\]\n\[sandbox: file access denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('a settled background denial renders no escalation hint without a confining executor (defensive arm)', async () => {
|
||||
// Structurally near-unreachable through the real stack — every confining
|
||||
// default advertises the static target set — but the read path guards
|
||||
// it anyway: an executor that reports no sandboxMode (fields never
|
||||
// advertised) whose task nonetheless carries denial facts must render
|
||||
// the marker without suggesting a lever the schema does not offer.
|
||||
class FactsOnlyExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: BashTaskId('bash-facts'),
|
||||
command: 'fake',
|
||||
status: 'completed',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
sandbox: { mode: 'read-only', denied: true },
|
||||
}
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 0,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
}
|
||||
|
||||
run(): Promise<BashRunResult> { return Promise.reject(new Error('not used')) }
|
||||
start(): BashTask { return this.task }
|
||||
get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined }
|
||||
list(): BashTask[] { return [this.task] }
|
||||
kill(): boolean { return false }
|
||||
ownerOf(): OwnerToken | undefined { return undefined }
|
||||
readOutput(): BashTaskRead {
|
||||
return { task: this.task, delta: '', lossy: false }
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FactsOnlyExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
const read = await call(ctx, 'bash_output', { task_id: 'bash-facts' })
|
||||
expect(text(read)).toMatch(/\[sandbox: file access denied under read-only mode\]$/)
|
||||
expect(text(read)).not.toContain('escalation available')
|
||||
})
|
||||
|
||||
it('bash_output reports a settled background RUNNER failure as a sandbox problem, outranking the denial marker', async () => {
|
||||
// A provider whose wrap carries a runner-failure signature: the settled
|
||||
// task's stderr matching it means the sandbox itself broke and the
|
||||
// command never ran — even though the same stderr also carries denial
|
||||
// words (a runner's error text may contain them).
|
||||
class FakeProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[]): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: ['permission denied'], runnerFailureSignatures: ['fake-runner: '] }
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FakeProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const started = await call(ctx, 'bash', { command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125', description: 'test command', run_in_background: true })
|
||||
const id = text(started).match(/started background task (bash-\d+)/)![1]
|
||||
await bash.list().find(task => task.id === id)!.done
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toMatch(/\[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; /)
|
||||
expect(text(read)).toMatch(/this is a sandbox problem, not a command failure\]$/)
|
||||
expect(text(read)).not.toContain('file access denied')
|
||||
})
|
||||
|
||||
it('classifies an executable configured runner that refuses its profile before the command runs', async () => {
|
||||
const signature = 'custom-runner-rejected'
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: ['bash', '-c', `printf '${signature}\\n' >&2; exit 125`, 'custom-runner'],
|
||||
runnerFailureSignatures: [signature],
|
||||
})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'echo command-must-not-run' })))
|
||||
.rejects.toMatchObject({ code: 'SANDBOX_UNAVAILABLE' })
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'echo command-must-not-run' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
})
|
||||
|
||||
it('reports a real denial end-to-end through the shipping sandbox executor', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-tool-bash-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await call(ctx, 'bash', { command: `echo x > ${lockedDir}/f`, description: 'Write into a locked directory' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(
|
||||
/denied under read-only mode\]\n\[sandbox: escalation available[^\n]+\]\n\[exit code: \d+\]$/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox escalation (sandbox_permissions / justification)', () => {
|
||||
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
|
||||
async function setupSandboxed(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', opts: { approval?: boolean; policy?: 'ask' | 'never' } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {})
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** The registered bash tool's wire schema (what the model actually sees). */
|
||||
function bashSchema(ctx: Context) {
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'bash')
|
||||
if (!schema) throw new Error('bash tool not registered')
|
||||
return schema as unknown as { description: string; parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake agent whose session records appends — the approval audit surface.
|
||||
* Seeded mid-turn: an escalating call always runs inside one, and request()
|
||||
* enforces the enclosure.
|
||||
*/
|
||||
function escalationAgent(events: Array<{ type: string; data: Record<string, unknown> }>): Agent {
|
||||
return {
|
||||
id: 'agent-esc',
|
||||
session: {
|
||||
header: { version: 0, id: 'sess-esc', createdAt: 0 },
|
||||
events: [{ type: 'turn/start' }],
|
||||
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
let escCall = 0
|
||||
function callAs(ctx: Context, agent: Agent | undefined, args: unknown) {
|
||||
return ctx.tools.execute({ callId: CallId(`call-esc-${++escCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
const ESCALATE = { command: 'true', description: 'test escalation', sandbox_permissions: 'workspace-write', justification: 'the test needs it' }
|
||||
|
||||
it('advertises no escalation surface under a non-sandboxing executor', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.bash.sandboxMode).toBeUndefined()
|
||||
const schema = bashSchema(ctx)
|
||||
expect(schema.parameters.properties['sandbox_permissions']).toBeUndefined()
|
||||
expect(schema.parameters.properties['justification']).toBeUndefined()
|
||||
expect(schema.description).not.toContain('sanctioned exception')
|
||||
})
|
||||
|
||||
it('advertises the full closed target vocabulary under any confining default', async () => {
|
||||
// The enum is deliberately NOT default-relative: a session's effective
|
||||
// mode is per-session and switchable, so every confining composition
|
||||
// advertises every possible target — strict widening is checked at
|
||||
// execution against the call's effective mode instead.
|
||||
for (const mode of [undefined, 'workspace-write', 'danger-full-access'] as const) {
|
||||
const { ctx } = await setupSandboxed(mode)
|
||||
const schema = bashSchema(ctx)
|
||||
expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.parameters.properties['justification']).toBeDefined()
|
||||
expect(schema.description).toContain('sanctioned exception')
|
||||
}
|
||||
})
|
||||
|
||||
it('a non-widening request fails at execution with its own text and prompts no one', async () => {
|
||||
const { ctx } = await setupSandboxed('danger-full-access', { approval: true })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const result = await callAs(ctx, escalationAgent([]), { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects sandbox_permissions without a justification, and vice versa, and a blank justification', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const missing = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write' })
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(text(missing)).toContain('sandbox_permissions requires a justification')
|
||||
const orphan = await callAs(ctx, undefined, { command: 'true', description: 'd', justification: 'why not' })
|
||||
expect(orphan.isError).toBe(true)
|
||||
expect(text(orphan)).toContain('only valid together with sandbox_permissions')
|
||||
const blank = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' })
|
||||
expect(blank.isError).toBe(true)
|
||||
expect(text(blank)).toContain('expected a non-empty sentence')
|
||||
})
|
||||
|
||||
it('the schema enum rejects a mode outside the target vocabulary before execute (registry-level, any caller)', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'read-only', justification: 'narrow' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must be one of')
|
||||
})
|
||||
|
||||
it('rejects an unadvertised sandbox_permissions injection under a non-sandboxing executor', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callAs(ctx, undefined, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'sneaky' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not available in this composition')
|
||||
})
|
||||
|
||||
it('fails closed with its own text when no approval service is composed', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval service is composed')
|
||||
})
|
||||
|
||||
it('fails closed with its own text for an agent-less escalating call', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const result = await callAs(ctx, undefined, ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no agent to route it through')
|
||||
})
|
||||
|
||||
it('fails closed with its own text when the service has no answerer', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval channel is available')
|
||||
})
|
||||
|
||||
it('a grant runs THAT call under the wider mode — the denial marker names it — and lands the audit pair', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
// A real unix denial under the passthrough runner: the marker's mode can
|
||||
// only say workspace-write if the override actually rode the spec.
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-esc-denied-')), 'locked')
|
||||
mkdirSync(lockedDir)
|
||||
chmodSync(lockedDir, 0o555)
|
||||
const result = await callAs(ctx, escalationAgent(events), {
|
||||
command: `echo x > ${lockedDir}/f`,
|
||||
description: 'write into a locked directory',
|
||||
sandbox_permissions: 'workspace-write',
|
||||
justification: 'must write outside the workspace',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toMatch(/\[sandbox: file access denied under workspace-write mode\]/)
|
||||
expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(events[0]?.data['toolName']).toBe('bash')
|
||||
expect(events[0]?.data['reason']).toBe('escalate sandbox to workspace-write: must write outside the workspace')
|
||||
expect(events[1]?.data['outcome']).toBe('allowed-once')
|
||||
})
|
||||
|
||||
it('a granted background start settles with the wider mode\'s facts', async () => {
|
||||
const { ctx, bash } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const started = await callAs(ctx, escalationAgent([]), { ...ESCALATE, run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
const id = text(started).match(/started background task (bash-\d+)/)?.[1]
|
||||
const task = bash.list().find(t => t.id === id)
|
||||
if (!task) throw new Error('escalated task not tracked')
|
||||
await task.done
|
||||
expect(task.sandbox).toMatchObject({ mode: 'workspace-write', denied: false })
|
||||
})
|
||||
|
||||
it('a rejection denies with the user-said-no text and runs nothing', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
// A live (non-aborted) signal rides the execution: the gate threads it
|
||||
// into the approval request so a turn cancellation can withdraw the ask.
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId(`call-esc-${++escCall}`),
|
||||
name: 'bash',
|
||||
arguments: ESCALATE,
|
||||
agent: escalationAgent([]),
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"')
|
||||
})
|
||||
|
||||
it('a cancellation denies with the cancelled text', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('approval for escalating to "workspace-write" was cancelled')
|
||||
})
|
||||
|
||||
it('a rogue approval stand-in returning a non-vocabulary outcome hits the exhaustiveness backstop', async () => {
|
||||
const { ctx } = await setupSandboxed()
|
||||
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as InstanceType<typeof ApprovalService>)
|
||||
const result = await callAs(ctx, escalationAgent([]), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('a never policy rejects an escalation deterministically without consulting any answerer', async () => {
|
||||
// The live-session e.md case: the model requests escalation against a
|
||||
// 'never' session — the prepend gate answers rejected before any
|
||||
// interactive answerer, the fail-closed text is the ordinary rejection
|
||||
// wording, and the audit pair still lands.
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true, policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const events: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
const result = await callAs(ctx, escalationAgent(events), ESCALATE)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(events[1]?.data).toMatchObject({ outcome: 'rejected' })
|
||||
})
|
||||
|
||||
it('a plain call under a sandboxing executor never consults approval', async () => {
|
||||
const { ctx } = await setupSandboxed('read-only', { approval: true })
|
||||
const asked = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { asked(); return next() })
|
||||
const result = await callAs(ctx, escalationAgent([]), { command: 'echo plain', description: 'plain run' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('plain')
|
||||
expect(asked).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
|
||||
async function setupModal(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'read-only', opts: { approval?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, mode })
|
||||
;(ctx.bash as SandboxBashExecutor).internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent stand-in over a REAL Session — the stamping folds real events;
|
||||
* the opened turn satisfies approval's enclosure precondition on escalating
|
||||
* calls.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
|
||||
const session = new Session(SessionId(id))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const injected: string[] = []
|
||||
const agent = {
|
||||
id,
|
||||
session,
|
||||
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
|
||||
} as unknown as Agent
|
||||
return { agent, session, injected }
|
||||
}
|
||||
|
||||
let modeCall = 0
|
||||
const callAs = (ctx: Context, agent: Agent | undefined, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`call-mode-${++modeCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
|
||||
|
||||
|
||||
it('stamps calls with grant > session override > nothing (executor default)', async () => {
|
||||
const ctx = await setupModal('read-only', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-stamp-1')
|
||||
const run = { command: 'true', description: 'stamp probe' }
|
||||
await callAs(ctx, agent, run) // no override yet
|
||||
setSandboxMode(session, 'workspace-write')
|
||||
await callAs(ctx, agent, run) // standing override
|
||||
await callAs(ctx, undefined, run) // agent-less caller: no session to fold
|
||||
await callAs(ctx, agent, { ...run, sandbox_permissions: 'danger-full-access', justification: 'grant outranks override' })
|
||||
expect(seen).toEqual([undefined, 'workspace-write', undefined, 'danger-full-access'])
|
||||
})
|
||||
|
||||
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
|
||||
// The blocker scenario: a workspace-write default with a read-only
|
||||
// override — the sensible escalation is workspace-write, which a
|
||||
// default-relative ladder could not even express. The static target
|
||||
// vocabulary advertises it and the execution check accepts it as
|
||||
// strictly wider than the CALL's effective (overridden) mode.
|
||||
const ctx = await setupModal('workspace-write', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-esc-narrow')
|
||||
setSandboxMode(session, 'read-only')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'the override is narrower than the default' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(seen).toEqual(['workspace-write'])
|
||||
})
|
||||
|
||||
it('a danger-full-access default still offers the lever to a narrower-switched session', async () => {
|
||||
// Under the default-relative ladder these fields VANISHED (nothing is
|
||||
// wider than the default), stranding a read-only-overridden session
|
||||
// with no escalation path at all.
|
||||
const ctx = await setupModal('danger-full-access', { approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const schema = ctx.tools.schemas().find(t => t.name === 'bash') as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
const { agent, session } = sessionAgent('sess-esc-dfa')
|
||||
setSandboxMode(session, 'read-only')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'confined by override under a wide default' })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-widening request against the OVERRIDDEN effective mode without prompting', async () => {
|
||||
const ctx = await setupModal('read-only', { approval: true })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const { agent, session } = sessionAgent('sess-esc-nonwide')
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider via override' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never stamps an override under a non-sandboxing executor (nothing honors it)', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: (string | undefined)[] = []
|
||||
const original = ctx.bash.resolve.bind(ctx.bash)
|
||||
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
|
||||
seen.push(req.sandboxMode)
|
||||
return original(req)
|
||||
})
|
||||
const { agent, session } = sessionAgent('sess-stamp-2')
|
||||
setSandboxMode(session, 'danger-full-access')
|
||||
await callAs(ctx, agent, { command: 'true', description: 'plain probe' })
|
||||
expect(seen).toEqual([undefined])
|
||||
})
|
||||
|
||||
})
|
||||
@@ -28,6 +28,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
key: 'agentLoop',
|
||||
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
|
||||
methods: [
|
||||
'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
|
||||
'create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
|
||||
'createAgent(options: CreateAgentOptions): AgentHandle',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
],
|
||||
@@ -73,6 +73,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'list(): Agent[]',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
summary: 'The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent\'s session log.',
|
||||
methods: [
|
||||
'async request(req: ApprovalRequest): Promise<ApprovalOutcome>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bash',
|
||||
summary: 'Abstract bash execution service.',
|
||||
@@ -125,6 +132,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
summary: 'Abstract process-sandbox service.',
|
||||
methods: [
|
||||
'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
summary: 'Abstract durable session-persistence service.',
|
||||
@@ -149,6 +163,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'skills',
|
||||
summary: 'Registry of skill providers.',
|
||||
methods: [
|
||||
'registerProvider(provider: SkillProvider): () => void',
|
||||
'register(skill: SkillRegistration): () => void',
|
||||
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'subagents',
|
||||
summary: 'The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface.',
|
||||
@@ -280,6 +304,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
signature: '\'agent/turn-continuation\'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
|
||||
summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.',
|
||||
},
|
||||
{
|
||||
name: 'approval/request',
|
||||
mode: 'waterfall',
|
||||
signature: '\'approval/request\'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>',
|
||||
summary: 'Waterfall asking the composed answerers to decide one approval request.',
|
||||
},
|
||||
{
|
||||
name: 'fs/edit-intent',
|
||||
mode: 'waterfall',
|
||||
@@ -322,6 +352,18 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
signature: '\'session/flush\'(session: Session): Promise<void> | void',
|
||||
summary: 'Awaited durability checkpoint.',
|
||||
},
|
||||
{
|
||||
name: 'skill/provider-added',
|
||||
mode: 'emit',
|
||||
signature: '\'skill/provider-added\'(provider: SkillProvider): void',
|
||||
summary: 'A skill provider became resolvable in the `ctx.skills` registry.',
|
||||
},
|
||||
{
|
||||
name: 'skill/provider-removed',
|
||||
mode: 'emit',
|
||||
signature: '\'skill/provider-removed\'(name: string): void',
|
||||
summary: 'A skill provider left the registry because its plugin fiber was disposed.',
|
||||
},
|
||||
{
|
||||
name: 'subagent/end',
|
||||
mode: 'emit',
|
||||
@@ -446,6 +488,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AgentStatus',
|
||||
declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
|
||||
},
|
||||
{
|
||||
name: 'ApprovalOutcome',
|
||||
declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
|
||||
},
|
||||
{
|
||||
name: 'ApprovalRequest',
|
||||
declaration: 'export interface ApprovalRequest {\n agent: Agent;\n toolName: string;\n callId?: CallId;\n reason?: string;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AskUserQuestionAnswer',
|
||||
declaration: 'export interface AskUserQuestionAnswer {\n answers: AskUserQuestionAnswerItem[];\n}',
|
||||
@@ -476,19 +526,23 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'BashExecRequest',
|
||||
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner?: OwnerToken | undefined;\n}',
|
||||
declaration: 'export interface BashExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner?: OwnerToken | undefined;\n sandboxMode?: SandboxMode | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashExecSpec',
|
||||
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner: OwnerToken | undefined;\n}',
|
||||
declaration: 'export interface BashExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n owner: OwnerToken | undefined;\n sandboxMode: SandboxMode | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashRunResult',
|
||||
declaration: 'export interface BashRunResult {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
|
||||
declaration: 'export interface BashRunResult {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n sandbox?: BashSandboxInfo;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashSandboxInfo',
|
||||
declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashTask',
|
||||
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n}',
|
||||
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BashTaskId',
|
||||
@@ -550,6 +604,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'CompactionResult',
|
||||
declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConfinedArgv',
|
||||
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConfinedSandboxMode',
|
||||
declaration: 'export type ConfinedSandboxMode = Exclude<SandboxMode, \'danger-full-access\'>;',
|
||||
},
|
||||
{
|
||||
name: 'ContentBlockMap',
|
||||
declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}',
|
||||
@@ -674,6 +736,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ResumeAgentOptions',
|
||||
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SandboxEnforcement',
|
||||
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
|
||||
},
|
||||
{
|
||||
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}',
|
||||
},
|
||||
{
|
||||
name: 'SendOptions',
|
||||
declaration: 'export interface SendOptions {\n source?: MessageSource;\n}',
|
||||
@@ -706,6 +780,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionLocation',
|
||||
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillCandidate',
|
||||
declaration: 'export interface SkillCandidate extends SkillSummary {\n rank: number;\n locator: unknown;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillDefinition',
|
||||
declaration: 'export interface SkillDefinition extends SkillSummary {\n content: string;\n path?: string;\n metadata?: Record<string, unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillLookupOptions',
|
||||
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n signal?: AbortSignal | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillProvider',
|
||||
declaration: 'export interface SkillProvider {\n name: string;\n list(options: SkillLookupOptions): Promise<SkillCandidate[]>;\n get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillRegistration',
|
||||
declaration: 'export type SkillRegistration = Omit<SkillDefinition, \'provider\'> & {\n provider?: string;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SkillResourceBase',
|
||||
declaration: 'export type SkillResourceBase = {\n kind: \'directory\';\n path: string;\n} | {\n kind: \'url\';\n url: string;\n} | {\n kind: \'opaque\';\n description: string;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SkillSource',
|
||||
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});',
|
||||
},
|
||||
{
|
||||
name: 'SkillSummary',
|
||||
declaration: 'export interface SkillSummary {\n name: string;\n description: string;\n whenToUse?: string;\n disableModelInvocation?: boolean;\n source: SkillSource;\n provider: string;\n resourceBase?: SkillResourceBase;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StreamChunk',
|
||||
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# core/ — product API spine
|
||||
|
||||
The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against.
|
||||
The session log, system-prompt assembly, tool registry, agent vocabulary, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -9,8 +9,8 @@ The packages every harness build is assembled from: the session log, the system-
|
||||
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
|
||||
| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle.
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-agent-core
|
||||
|
||||
The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
|
||||
|
||||
This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle.
|
||||
|
||||
@@ -14,9 +14,12 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-session event-sourced session log + store
|
||||
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
@@ -27,6 +30,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
@@ -35,11 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement
|
||||
|
||||
```ts
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// { agents?, persona?, toolOrder? } — the schema is z.intersect([AgentLoop.Config, SystemPrompt.Config]),
|
||||
// so validation and defaulting can never drift from the owners'.
|
||||
// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas,
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section — and `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-agent-core",
|
||||
"description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)",
|
||||
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + invariants + tool-bash + tool-skill + agent-loop)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,8 +28,11 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
@@ -40,8 +43,11 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* The providerless, executor-less, UI-less agent spine as ONE bundle plugin.
|
||||
* The default executor-less, UI-less agent spine as ONE bundle plugin.
|
||||
*
|
||||
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
|
||||
* service, the session store, system-prompt assembly, the tool registry, the
|
||||
* agent registry, the dev-mode invariants, the model-facing `bash` tool
|
||||
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* skill registry plus local skill provider, the agent registry, the dev-mode
|
||||
* invariants, the model-facing `bash` and `skill` tool schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
|
||||
* list as its OWN config (default `[]`), so each app supplies its own
|
||||
* pre-created agents.
|
||||
*
|
||||
@@ -19,6 +19,9 @@
|
||||
* (a console logger, `hmr`) — these are the coupled "front-door cluster" the
|
||||
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
|
||||
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
|
||||
* - additional SKILL PROVIDERS; the bundle ships the local filesystem provider
|
||||
* because local skills are default agent behavior, while embedded or remote
|
||||
* providers remain deployment choices.
|
||||
*
|
||||
* This is the interface/implementation/consumer seam at the composition level:
|
||||
* the bundle owns the shared spine, the leaf owns the backends, the app package
|
||||
@@ -49,24 +52,37 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
export const name = 'agent-core'
|
||||
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level discovery cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
/** Model-facing skill catalog and tool settings. */
|
||||
tool?: toolSkill.Config
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`).
|
||||
* Every field is optional INPUT here because each owner's schema
|
||||
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
|
||||
* schema is the INTERSECTION of the owners' own schemas (the registry's
|
||||
* nested under its `tools` key), so validation and defaulting can never
|
||||
* drift from them.
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* is optional INPUT here because each owner's schema supplies the default;
|
||||
* the schema is the INTERSECTION of the owners' own schemas (with registry
|
||||
* schemas nested under their bundle keys), so validation and defaulting can
|
||||
* never drift from them.
|
||||
*/
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
@@ -77,10 +93,23 @@ export interface Config {
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical (the registry's nested under `tools`). */
|
||||
export const Config = z.intersect([AgentLoop.Config, SystemPrompt.Config, z.object({ tools: ToolRegistry.Config })]) as unknown as z<Config>
|
||||
/** The skill config schema exported for app packages that forward `skills`. */
|
||||
export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
registry: SkillService.Config,
|
||||
local: SkillLocal.Config,
|
||||
tool: toolSkill.Config,
|
||||
})
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
export const Config = z.intersect([
|
||||
AgentLoop.Config,
|
||||
SystemPrompt.Config,
|
||||
z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
/**
|
||||
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
|
||||
@@ -106,8 +135,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
})
|
||||
ctx.plugin(ToolRegistry, config.tools ?? {})
|
||||
ctx.plugin(SkillService, config.skills?.registry ?? {})
|
||||
ctx.plugin(SkillLocal, config.skills?.local ?? {})
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolSkill, config.skills?.tool ?? {})
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
|
||||
* up the whole providerless spine in one `ctx.plugin`, and the forwarded
|
||||
* up the whole default spine in one `ctx.plugin`, and the forwarded
|
||||
* `agents` config reaches the loop (default `[]`, or a pre-created agent).
|
||||
*
|
||||
* The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE
|
||||
@@ -16,16 +28,54 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
* bin smokes; here we assert the composition + config forwarding.
|
||||
*/
|
||||
async function mount(config?: agentCore.Config): Promise<Context> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(agentCore, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services and any pre-created agent are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
return ctx
|
||||
try {
|
||||
await ctx.plugin(agentCore, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services and any pre-created agent are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
return ctx
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
|
||||
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-agent-core bundle', () => {
|
||||
it('brings up the full providerless spine', async () => {
|
||||
it('brings up the full default spine', async () => {
|
||||
const ctx = await mount()
|
||||
// One service from each layer of the spine proves the children loaded.
|
||||
expect(ctx.get('timer')).toBeDefined()
|
||||
@@ -33,11 +83,22 @@ describe('dsh-agent-core bundle', () => {
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('systemPrompt')).toBeDefined()
|
||||
expect(ctx.get('tools')).toBeDefined()
|
||||
expect(ctx.get('skills')).toBeDefined()
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
|
||||
const ctx = await mount()
|
||||
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defaults the agents list to empty (no pre-created agents)', async () => {
|
||||
const ctx = await mount()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
@@ -68,6 +129,40 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
|
||||
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
|
||||
const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-'))
|
||||
await mkdir(custom, { recursive: true })
|
||||
await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
|
||||
const ctx = await mount({
|
||||
agents: [],
|
||||
skills: {
|
||||
registry: { collectCacheMaxEntries: 4 },
|
||||
local: {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(agentsHome, '.agents'),
|
||||
customSkillDirs: [custom],
|
||||
},
|
||||
tool: { catalogDescriptionMaxLength: 6 },
|
||||
},
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['custom-skill'])
|
||||
expect(JSON.stringify(await composePrefix(ctx, '/tmp'))).toContain('- `custom-skill`: Cus...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
agentCore.apply(ctx, { agents: [] })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards toolOrder to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
@@ -81,7 +176,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
@@ -29,6 +32,15 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/tool-skill"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
@@ -28,11 +28,12 @@ interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup. (There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context.) The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
|
||||
### Classes
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -38,6 +38,8 @@ export interface Config {
|
||||
agents: (AgentOptions & {
|
||||
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
|
||||
id: AgentId
|
||||
/** Optional workspace cwd for the config-created fresh session. */
|
||||
cwd?: string
|
||||
/**
|
||||
* If set, the config agent RESUMES this persisted session id instead of
|
||||
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
|
||||
@@ -77,6 +79,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
model: z.string(),
|
||||
cwd: z.string(),
|
||||
resumeSessionId: z.string(),
|
||||
})).default([]),
|
||||
}) as unknown as z<Config>
|
||||
@@ -96,7 +99,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// (renderPrompt then rejects a persona that claims it — fail loud).
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
for (const { id, resumeSessionId, ...options } of config.agents) {
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
||||
// Resume a prior session instead of starting fresh. resume() needs
|
||||
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
|
||||
@@ -115,15 +118,15 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
return () => void fiber.dispose()
|
||||
}, `agentLoop.resume(${id})`)
|
||||
} else {
|
||||
this.create(id, options)
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`, no cwd). Used for `cordis.yml`-configured agents
|
||||
* and as the shared core for the programmatic factory {@link createAgent}.
|
||||
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
|
||||
* the shared core for the programmatic factory {@link createAgent}.
|
||||
*
|
||||
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
|
||||
* backend is loaded, a fixed id collides on the second run — the backend
|
||||
@@ -137,15 +140,16 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* UI/ACP path owns session selection.
|
||||
* @param id - the agent id; also seeds the generated session id.
|
||||
* @param options - loop options (model, limits, …); defaults applied per option.
|
||||
* @param meta - optional session metadata for the fresh session.
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
// Config/programmatic path: prepare the session and let start() fold its
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -916,6 +916,21 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('attaches config agent cwd to the fresh session header', async () => {
|
||||
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: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }],
|
||||
})
|
||||
|
||||
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
|
||||
expect(agent.session.header.cwd).toBe('/work/project')
|
||||
})
|
||||
|
||||
it('replays a session log into an identical derived history', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
|
||||
@@ -22,7 +22,7 @@ tools:
|
||||
|
||||
### Injected services
|
||||
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -38,14 +38,14 @@ tools:
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -34,6 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
@@ -83,8 +86,8 @@ declare module 'cordis' {
|
||||
* or return a {@link PreToolDecision} without calling `next()` to
|
||||
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` degrades to deny until the permission
|
||||
* system lands (`FIXME(permissions)`).
|
||||
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
|
||||
* when one is mounted, and degrades to deny otherwise.
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
@@ -268,8 +271,9 @@ export interface ToolExecutionResult {
|
||||
* would desync the UI from what RAN. That consistency redesign is its own
|
||||
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
|
||||
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
|
||||
* - `ask` is the permission-prompt intent; until the permission system exists it
|
||||
* degrades to `deny` (`FIXME(permissions)`).
|
||||
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
|
||||
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
|
||||
* dispatch; every other outcome denies), degrading to `deny` when none is.
|
||||
*/
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
@@ -486,22 +490,17 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. ---
|
||||
const decision = await this.ctx.waterfall(
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the approval
|
||||
// seam (or degrades) to allow/deny before the shared deny path. ---
|
||||
const gate = await this.ctx.waterfall(
|
||||
this, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
if (decision.kind !== 'allow') {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const reason = decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${reason}` }],
|
||||
content: [{ type: 'text', text: `Error: ${decision.reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
@@ -540,6 +539,44 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
||||
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
||||
* deployment that composes no ApprovalService keeps the historical degrade
|
||||
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
||||
* An agent-less execution also degrades: without an agent there is no
|
||||
* session to audit to and no UI to route to. Otherwise the outcome maps
|
||||
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
||||
* distinct reasons so the model can tell a human "no" from an absent
|
||||
* approval channel.
|
||||
*/
|
||||
private async serviceAsk(
|
||||
exec: ToolExecution,
|
||||
ask: Extract<PreToolDecision, { kind: 'ask' }>,
|
||||
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
|
||||
const approval = this.ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: exec.name,
|
||||
callId: exec.callId,
|
||||
...ask.reason !== undefined ? { reason: ask.reason } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
case 'allowed-once': return { kind: 'allow' }
|
||||
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
|
||||
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
|
||||
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
|
||||
@@ -35,7 +35,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', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', '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) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
@@ -158,7 +160,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -181,6 +183,107 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
|
||||
})
|
||||
|
||||
describe('ask routing through ctx.approval', () => {
|
||||
/**
|
||||
* A minimal Agent stand-in — the approval seam reaches
|
||||
* `agent.session.append` and folds `.events`; the seeded open turn
|
||||
* satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function fakeAgent(): Agent {
|
||||
return {
|
||||
session: { events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
async function approvalSetup() {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(ApprovalService)
|
||||
ctx.tools.register(echoTool)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('dispatches the tool when the answerer grants allowed-once, forwarding the ask fields', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
const agent = fakeAgent()
|
||||
const controller = new AbortController()
|
||||
const seen: ApprovalRequest[] = []
|
||||
ctx.on('approval/request', (req) => {
|
||||
seen.push(req)
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
|
||||
({ kind: 'ask', reason: 'hook wants a human' }))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: 'hi' }] })
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]).toMatchObject({ agent, toolName: 'echo', callId: 'c1', reason: 'hook wants a human' })
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('denies with the user-rejection reason on rejected', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
|
||||
})
|
||||
|
||||
it('denies with the cancellation reason on cancelled', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
|
||||
})
|
||||
|
||||
it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
|
||||
})
|
||||
|
||||
it('denies an agent-less execution without asking — nothing to route or audit through', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
let asked = false
|
||||
ctx.on('approval/request', () => {
|
||||
asked = true
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(asked).toBe(false)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
|
||||
})
|
||||
|
||||
it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => {
|
||||
// ApprovalService normalizes rogue answers itself; this pins the
|
||||
// registry's own exhaustiveness backstop by shadowing the service with a
|
||||
// stand-in that violates the outcome contract.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
|
||||
expect(text).toContain('unreachable')
|
||||
})
|
||||
})
|
||||
|
||||
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
owner: request.owner,
|
||||
sandboxMode: request.sandboxMode,
|
||||
}
|
||||
},
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# sandbox/ — process-sandbox capability family
|
||||
|
||||
The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. 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.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `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`) |
|
||||
|
||||
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 RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [examples/sandbox-acp-agent](../../examples/sandbox-acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
|
||||
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-sandbox-local
|
||||
|
||||
Local implementation of the [`@deepseek-ai/dsh-sandbox`](../sandbox/) seam: wraps a caller's argv in a platform confinement runner. Selection is BY PLATFORM, resolved once and cached: each platform names its runner chain, a chain of one is selected directly (probing arbitrates between candidates — a sole candidate leaves nothing to arbitrate), and a chain of several is probed functionally in preference order. Linux: [`bwrap`](https://github.com/containers/bubblewrap) when its probe passes, else the [`landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) Landlock launcher (kernel confinement that needs no userns/mount privileges — see the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the prebuilt-binary decision and profile-parity notes); darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. A platform with no chain means `confine()` FAILS CLOSED with the seam's structured `SANDBOX_UNAVAILABLE` error (win32 today: a reserved, deliberately empty chain awaiting an AppContainer-family runner); an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap's `runnerFailureSignatures` let the consumer classify that as a sandbox failure rather than a task failure. Never a silent unconfined passthrough on any path.
|
||||
|
||||
Policy is per call (`SandboxPolicy`: mode + workspace root); the provider holds only the mechanism and the cached ladder verdict. Every wrap reports the selected runner's `enforcement` (`full`, or `partial` on an older Landlock ABI that governs only a subset of accesses — read from the launcher's `--probe` report line) and its `denialSignatures` — the stderr dialect that rung's kernel speaks on a denied file effect (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt), which stderr-inferring consumers match instead of a cross-runner union. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile: the ladder and probes are skipped (the wrap carries both Linux denial dialects, the mechanism being unknown) — also the deterministic fake-runner seam for keyless test tiers. Its runner-failure dialect is the OUTER shell's argv0-scoped failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) — the consumer re-joins the wrap through `bash -c 'exec …'`, so a missing or unexecutable configured runner classifies as a sandbox failure (fail closed at execution), never as a failing command or a denial. `probeTimeoutMs` (default 5000) bounds each functional probe, the escape hatch for hosts slow enough that a timed-out probe would otherwise misread as `SANDBOX_UNAVAILABLE`.
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
The Landlock launcher comes from the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) — an entry package (this package's one runtime dependency) plus per-platform binary packages selected by npm's `os`/`cpu` fields, built and released from [its own repository](https://github.com/deepseek-harness/node-addon-landlock-run). The entry package owns the launcher's CLI contract: `launcherPath()` resolution (a host with no platform package yields a never-existing path whose probe fails exactly like an unenforcing kernel), the functional `probe()`, and `grantArgs()` flag spelling — versioned together with the binary, so probe-report parsing can never drift against it. This provider keeps only the policy side: the mode → grants mapping (`landlockProfileArgs`) and the ladder. The consumer path is rehearsed by `tests/packed-install.e2e.ts`: pack THIS package's closure, install into a throwaway consumer with the launcher family coming from the registry, assert the installed binary executable (a stripped mode bit must not masquerade as a non-enforcing kernel), and confine through it under plain `node`.
|
||||
|
||||
Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, `tests/seatbelt.e2e.ts`), each self-skipping where its runner is absent; CI's `sandbox-e2e` matrix runs all of them against real kernels (bwrap plus one Landlock leg per architecture on Linux, Seatbelt on macOS) and fails on a silent all-skip.
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
```
|
||||
|
||||
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [`examples/sandbox-acp-agent`](../../../examples/sandbox-acp-agent/) for the runnable composition.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-local",
|
||||
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* `LocalSandboxProvider`: the local implementation of the
|
||||
* `@deepseek-ai/dsh-sandbox` seam. Wraps a caller's argv in a platform
|
||||
* confinement runner selected BY PLATFORM: each platform names its runner
|
||||
* chain ({@link PLATFORM_CHAINS}), a chain of one is selected directly (no
|
||||
* probe — there is nothing to arbitrate), and a chain of several is probed
|
||||
* FUNCTIONALLY in preference order (build and enforce a real profile once,
|
||||
* not `--version`), the verdict cached for the provider's lifetime. Linux:
|
||||
* `bwrap`, else the `landlock-run` Landlock launcher (kernel confinement
|
||||
* that needs no userns/mount privileges; distributed as the npm package
|
||||
* family `node-addon-landlock-run` — the decision recorded in
|
||||
* docs/rfc/implemented/feature/2026-07-06-sandbox.md); darwin: macOS
|
||||
* `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed.
|
||||
* When the platform has no chain or no candidate passes,
|
||||
* {@link LocalSandboxProvider.confine} FAILS CLOSED with the seam's
|
||||
* structured `SANDBOX_UNAVAILABLE` error instead of passing the argv
|
||||
* through unconfined; an unusable runner selected WITHOUT a probe fails
|
||||
* closed at execution time instead (it refuses to run the command), which
|
||||
* the wrap's `runnerFailureSignatures` let consumers classify as a sandbox
|
||||
* failure rather than a task failure.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { grantArgs as landlockGrantArgs, LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
|
||||
* appended). A NON-EMPTY argv is the operator's assertion that this runner
|
||||
* exists and FULLY enforces the profile (confinement reports
|
||||
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
|
||||
* — carries both Linux file-denial dialects as its denial signatures) —
|
||||
* the runner chain and its probes are skipped,
|
||||
* and a broken runner fails loudly at execution time. The operator also
|
||||
* supplies {@link runnerFailureSignatures}, which distinguish the runner
|
||||
* refusing its profile from the wrapped command failing normally.
|
||||
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
|
||||
* built-in platform chains — Linux `bwrap` then the Landlock launcher
|
||||
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
|
||||
* selected without a probe). Used for custom/alternative runners and
|
||||
* for deterministic fake runners in keyless test tiers.
|
||||
*/
|
||||
runnerCommand?: string[]
|
||||
/**
|
||||
* Case-insensitive stderr substrings emitted when a configured
|
||||
* {@link runnerCommand} refuses its profile before executing the wrapped
|
||||
* command. Required and non-empty with `runnerCommand`; rejected without
|
||||
* it. Missing/unexecutable runner errors are added automatically from
|
||||
* `runnerCommand[0]`, while these signatures cover an executable runner's
|
||||
* own failure dialect.
|
||||
*/
|
||||
runnerFailureSignatures?: string[]
|
||||
/**
|
||||
* Per-probe timeout in milliseconds for the chain's functional probes
|
||||
* (default: 5000; must be a positive finite number — Node treats a 0
|
||||
* `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A
|
||||
* probe that exceeds it reads as an unusable rung, so a
|
||||
* host slow enough to trip the default — cold NFS mounts, heavily loaded
|
||||
* CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no
|
||||
* config escape. Bounds ONE probe, and the chain walk runs each at most once
|
||||
* per provider lifetime.
|
||||
*/
|
||||
probeTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The `bwrap` profile arguments for one policy. The whole host tree is bound
|
||||
* read-only; a fresh `/dev` keeps `>/dev/null` redirects working and a fresh
|
||||
* `/proc` keeps process-inspecting tools working. `workspace-write`
|
||||
* additionally mounts an ephemeral writable `/tmp` and rebinds the workspace
|
||||
* root read-write (bind order matters: later binds overlay earlier ones).
|
||||
* Deliberately NO `--unshare-pid` (it would break the process-group kill
|
||||
* semantics shell consumers rely on) and NO network unsharing (the seam's
|
||||
* mode vocabulary promises file effects only).
|
||||
* @param policy - the file-effect policy to express as bwrap arguments.
|
||||
* @returns the bwrap profile arguments (before the trailing `--` + argv).
|
||||
*/
|
||||
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
args.push('--tmpfs', '/tmp')
|
||||
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* The `landlock-run` grant arguments for one policy — the bwrap
|
||||
* profile's file-effect semantics expressed as a Landlock allow-list
|
||||
* (Landlock cannot mount, so there are no fresh/ephemeral filesystems). The
|
||||
* whole tree is readable and executable; of `/dev`, ONLY `/dev/null` is
|
||||
* writable — a whole-`/dev` grant would expose real host paths beneath it
|
||||
* (`/dev/shm`, a shared tmpfs) to persistent writes, which `read-only`
|
||||
* promises never happen. bwrap can hand out a fresh ephemeral `/dev`; on the
|
||||
* host's own `/dev` the write grant must be node-by-node, and `>/dev/null`
|
||||
* is the one redirects need. `workspace-write` adds the HOST `/tmp` (shared
|
||||
* and persistent, where bwrap's is ephemeral — the honest difference,
|
||||
* recorded in the sandbox RFC's runner notes) plus the workspace
|
||||
* root read-write. The flag spelling belongs to `node-addon-landlock-run`'s
|
||||
* `grantArgs`; this function owns only the policy → grants mapping.
|
||||
* @param policy - the file-effect policy to express as launcher grants.
|
||||
* @returns the launcher grant arguments (before `--` + argv).
|
||||
*/
|
||||
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const readWrite = ['/dev/null']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
readWrite.push('/tmp', policy.workspaceRoot)
|
||||
}
|
||||
return landlockGrantArgs({ readOnly: ['/'], readWrite })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a granted root to the path the kernel actually sees. Seatbelt path
|
||||
* filters match the CANONICAL path (symlinks resolved), and the roots this
|
||||
* profile grants are symlinked on every macOS: `/tmp` is `/private/tmp` and
|
||||
* the user temp dir lives under `/var` → `/private/var` — an as-spelled
|
||||
* grant would match nothing.
|
||||
*/
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// realpathSync failed: the path (or a prefix) is missing or unreadable.
|
||||
// Grant the spelling as-is — an unresolvable root matches nothing until
|
||||
// it exists, which is the conservative outcome, and inventing a fallback
|
||||
// resolution here would grant a path the caller never named.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote one path as an SBPL string literal (backslashes and double quotes escaped). */
|
||||
function sbplString(path: string): string {
|
||||
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL)
|
||||
* profile with the same file-effect semantics as the other dialects, built
|
||||
* as allow-default → `(deny file-write*)` → write allow-list (later rules
|
||||
* win), so exactly the mode's promised file effects are governed — network
|
||||
* and process visibility stay unrestricted, which is all the seam's mode
|
||||
* vocabulary claims. Of `/dev`, ONLY the `/dev/null` literal is writable
|
||||
* (the same node-not-directory reasoning as the Landlock grant).
|
||||
* `workspace-write` adds the workspace root, the host `/tmp`, and the
|
||||
* per-user darwin temp dir (`os.tmpdir()`, launchd's `TMPDIR`, inherited by
|
||||
* the confined child) — on darwin that directory IS the platform's `/tmp`
|
||||
* for every mkstemp-family tool, so omitting it would deny the mode's
|
||||
* promised temp area. All granted roots are canonicalized because Seatbelt
|
||||
* matches resolved paths ({@link canonicalPath}); duplicates after
|
||||
* resolution collapse.
|
||||
* @param policy - the file-effect policy to express as an SBPL profile.
|
||||
* @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv).
|
||||
*/
|
||||
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
|
||||
if (policy.mode === 'workspace-write') {
|
||||
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
|
||||
}
|
||||
return ['-p', forms.join(' ')]
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional `bwrap` probe: can it actually build the read-only profile on
|
||||
* this host? (`--version` alone would miss a disabled unprivileged user
|
||||
* namespace.) Synchronous by design — it runs once, lazily, before the first
|
||||
* confined wrap, and the chain's verdict is cached for the provider's
|
||||
* lifetime. `timeoutMs` bounds the probe (the `probeTimeoutMs` config).
|
||||
* The Landlock rung needs no such helper: resolution (`launcherPath`) and
|
||||
* the functional probe (`probe`) come from `node-addon-landlock-run`, the
|
||||
* package family that ships the launcher binary itself, so the probe-report
|
||||
* parsing can never drift against the binary.
|
||||
*/
|
||||
function defaultProbeBwrap(timeoutMs: number): boolean {
|
||||
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
|
||||
timeout: timeoutMs,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional Seatbelt probe: apply the real `read-only` profile through
|
||||
* `sandbox-exec -p` and run `true` under it — exit 0 means the kernel
|
||||
* accepted and enforced the profile (`sandbox-exec` exits non-zero when
|
||||
* `sandbox_init` refuses it). A missing `sandbox-exec` (every non-macOS
|
||||
* host) fails the spawn and probes `unusable`, exactly like the other
|
||||
* rungs' absent binaries. Apple marks the CLI deprecated but ships it on
|
||||
* every macOS; if it ever disappears, this probe is what fails closed.
|
||||
*/
|
||||
function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean {
|
||||
const probe = spawnSync(seatbeltExec, [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
|
||||
timeout: timeoutMs,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/** Test seam: inject probe verdicts / a fake launcher / a platform without real runners. */
|
||||
export interface SandboxInternals {
|
||||
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
|
||||
platform?: string
|
||||
/** Replaces the platform's chain wholesale (walk mechanics — e.g. probing a rung the product chains only reach unprobed). */
|
||||
chain?: readonly SelectedRunner['runner'][]
|
||||
/** Replaces the functional `bwrap` probe (the Linux chain's first rung). */
|
||||
probeBwrap?: () => boolean
|
||||
/** Replaces the functional Landlock launcher probe (the Linux chain's second rung). */
|
||||
probeLandlock?: (launcher: string) => SandboxEnforcement | 'unusable'
|
||||
/** Replaces the functional Seatbelt probe (the darwin chain's sole rung — only consulted if that chain ever grows). */
|
||||
probeSeatbelt?: (seatbeltExec: string) => boolean
|
||||
/** Replaces the resolved `landlock-run` launcher path (a fake launcher script). */
|
||||
landlockLauncher?: string
|
||||
/** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
|
||||
seatbeltExec?: string
|
||||
}
|
||||
|
||||
/** The chain's verdict: which runner confines, and how completely it enforces. */
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement }
|
||||
|
||||
/**
|
||||
* The runner chain per platform — selection is BY PLATFORM first, probes
|
||||
* second: a platform's chain is probed in preference order only when it has
|
||||
* MORE than one candidate (probing arbitrates; it does not re-validate a
|
||||
* choice that has no alternative). A platform with no chain fails closed at
|
||||
* `confine()`. Linux prefers `bwrap` (its mount profile is closest to the
|
||||
* mode vocabulary) over the Landlock launcher; darwin has exactly one
|
||||
* candidate, selected without any probe.
|
||||
*/
|
||||
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
|
||||
linux: ['bwrap', 'landlock'],
|
||||
darwin: ['seatbelt'],
|
||||
// Reserved slot, deliberately empty: Windows support fills it with a
|
||||
// confinement runner (AppContainer / restricted-token family, shipped from
|
||||
// its own repository on the landlock-run template) plus a
|
||||
// SelectedRunner['runner'] union member — the switches' assertNever guards
|
||||
// then walk the implementer to every site. An empty chain fails closed at
|
||||
// confine(), identical to an unlisted platform: reserving the slot never
|
||||
// weakens the fail-closed end.
|
||||
win32: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforcement completeness a rung claims when selected WITHOUT a probe (a
|
||||
* chain of one). `bwrap` and Seatbelt govern every promised file effect by
|
||||
* construction, so the claim is a profile fact; `landlock` is listed for the
|
||||
* table's totality but is unreachable unprobed today (the Linux chain has
|
||||
* two rungs, so it is only ever selected through its probe, whose report is
|
||||
* what distinguishes full from per-ABI-partial — and the launcher additionally
|
||||
* self-reports partial enforcement on stderr at every confined run).
|
||||
*/
|
||||
const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
|
||||
bwrap: 'full',
|
||||
landlock: 'full',
|
||||
seatbelt: 'full',
|
||||
}
|
||||
|
||||
/**
|
||||
* A probe bound must be a positive finite number: Node treats
|
||||
* `spawnSync({ timeout: 0 })` as NO timeout, so an unvalidated 0 would
|
||||
* silently mean "unbounded" — the opposite of what the field promises.
|
||||
*/
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`sandbox-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The denial dialect each runner's kernel speaks — the case-insensitive
|
||||
* stderr substrings a denied file effect produces under it, carried on every
|
||||
* wrap (the seam's `ConfinedArgv.denialSignatures`). Kernel facts, not
|
||||
* tunables: bwrap denies through its read-only bind mounts (EROFS), Landlock
|
||||
* refuses with EACCES, Seatbelt with EPERM — whose text is also what
|
||||
* non-file EPERM boundaries print, the residual imprecision the consumer's
|
||||
* conservative classifier documents. An operator-configured `runnerCommand`
|
||||
* has an unknown kernel mechanism, so its wraps carry both Linux file-denial
|
||||
* dialects; bare EPERM stays excluded there (it names non-file boundaries
|
||||
* the mode vocabulary does not govern).
|
||||
*/
|
||||
const DENIAL_SIGNATURES = {
|
||||
bwrap: ['read-only file system'],
|
||||
landlock: ['permission denied'],
|
||||
seatbelt: ['operation not permitted'],
|
||||
runnerCommand: ['read-only file system', 'permission denied'],
|
||||
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
|
||||
|
||||
/**
|
||||
* How each runner's OWN failure identifies itself on stderr (the seam's
|
||||
* `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error
|
||||
* lines with its program name, and the shell's runner-not-found message
|
||||
* carries the same `name: ` shape (`bash: bwrap: command not found`,
|
||||
* `bash: …/bin/landlock-run: No such file or directory`) — so one substring
|
||||
* per runner covers both "runner broke" and "runner missing". Consumers
|
||||
* match these BEFORE the denial dialect: a runner's error text can contain
|
||||
* denial words (an unopenable grant root reports `Permission denied`), and
|
||||
* a runner failure means the command never ran at all.
|
||||
*/
|
||||
const RUNNER_FAILURE_SIGNATURES = {
|
||||
bwrap: ['bwrap: '],
|
||||
landlock: [`${LAUNCHER_BIN}: `],
|
||||
seatbelt: ['sandbox-exec: '],
|
||||
} as const satisfies Record<SelectedRunner['runner'], readonly string[]>
|
||||
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
|
||||
* apart from the cached chain verdict — it spawns nothing but the one-time
|
||||
* probes, so there is no disposal work beyond cordis' own.
|
||||
*/
|
||||
export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
static Config: z<Config> = z.object({
|
||||
runnerCommand: z.array(z.string()).default([]),
|
||||
runnerFailureSignatures: z.array(z.string()).default([]),
|
||||
probeTimeoutMs: z.natural().default(5_000),
|
||||
})
|
||||
|
||||
/** Test seam (mirrors the bash executors' `internals`). */
|
||||
internals: SandboxInternals = {}
|
||||
|
||||
private readonly runnerCommand: string[] | undefined
|
||||
private readonly configuredRunnerFailureSignatures: string[]
|
||||
private readonly probeTimeoutMs: number
|
||||
/** Cached chain verdict; undefined until the first confined wrap needs it. */
|
||||
private selectedRunner: SelectedRunner | 'unavailable' | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// The schema (static Config) defaults every field — the casts record
|
||||
// those runtime facts. An empty runnerCommand means "not configured":
|
||||
// use the platform chain.
|
||||
const runner = config.runnerCommand as string[]
|
||||
const runnerFailureSignatures = config.runnerFailureSignatures as string[]
|
||||
if (runner.length === 0 && runnerFailureSignatures.length > 0) {
|
||||
throw new Error('sandbox-local: runnerFailureSignatures requires runnerCommand')
|
||||
}
|
||||
if (runner.length > 0 && runnerFailureSignatures.length === 0) {
|
||||
throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry')
|
||||
}
|
||||
if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) {
|
||||
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty')
|
||||
}
|
||||
this.runnerCommand = runner.length > 0 ? runner : undefined
|
||||
this.configuredRunnerFailureSignatures = runnerFailureSignatures
|
||||
this.probeTimeoutMs = config.probeTimeoutMs as number
|
||||
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `argv` in the selected runner's invocation for `policy` — the
|
||||
* configured `runnerCommand` when present (the operator's assertion, no
|
||||
* probe), else the platform chain's runner speaking its own profile
|
||||
* dialect. Every wrap carries the runner's enforcement completeness, its
|
||||
* denial dialect, and its runner-failure signatures.
|
||||
* @param argv - the exact argv the caller is about to spawn.
|
||||
* @param policy - the file-effect policy this execution runs under.
|
||||
* @returns the wrapped argv plus the selected backend's enforcement
|
||||
* completeness, denial signatures, and runner-failure signatures;
|
||||
* throws the fail-closed `SANDBOX_UNAVAILABLE` error when the platform
|
||||
* has no usable runner.
|
||||
*/
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
if (this.runnerCommand !== undefined) {
|
||||
const argv0 = this.runnerCommand[0] as string
|
||||
return {
|
||||
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
|
||||
// The operator names the configured runner's OWN pre-exec refusal
|
||||
// dialect; the consumer additionally re-joins the wrap through an
|
||||
// outer `bash -c 'exec …'`, so we can add the missing/unexecutable
|
||||
// outer-shell shapes ourselves. Scoping every automatic shape to
|
||||
// argv0 keeps in-command errors out (a bare `exec:`/`Permission
|
||||
// denied` prefix would claim tool output; `exec: <argv0>: not found`
|
||||
// cannot). The residual text-collision trade is documented by the
|
||||
// seam's conservative classifier contract.
|
||||
runnerFailureSignatures: [
|
||||
...this.configuredRunnerFailureSignatures,
|
||||
`exec: ${argv0}: not found`,
|
||||
`${argv0}: No such file or directory`,
|
||||
`${argv0}: Permission denied`,
|
||||
],
|
||||
}
|
||||
}
|
||||
const selected = this.selectRunner(policy.mode)
|
||||
return {
|
||||
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
|
||||
enforcement: selected.enforcement,
|
||||
denialSignatures: DENIAL_SIGNATURES[selected.runner],
|
||||
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
|
||||
}
|
||||
}
|
||||
|
||||
/** The selected rung's runner invocation (program + profile arguments) for one policy. */
|
||||
private runnerArgv(runner: SelectedRunner['runner'], policy: SandboxPolicy): string[] {
|
||||
switch (runner) {
|
||||
case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
|
||||
case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
|
||||
case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which runner confines commands, once, for the provider's
|
||||
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
|
||||
* candidate selected directly, multiple candidates arbitrated by
|
||||
* functional probes in chain order. Fail closed when the platform has no
|
||||
* chain or no candidate passes — the command never runs.
|
||||
*/
|
||||
private selectRunner(mode: ConfinedSandboxMode): SelectedRunner {
|
||||
this.selectedRunner ??= this.chainVerdict()
|
||||
if (this.selectedRunner === 'unavailable') throw new SandboxUnavailableError(mode)
|
||||
return this.selectedRunner
|
||||
}
|
||||
|
||||
/** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
|
||||
private chainVerdict(): SelectedRunner | 'unavailable' {
|
||||
const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []
|
||||
const [first, ...rest] = chain
|
||||
if (first === undefined) return 'unavailable'
|
||||
// One candidate = nothing to arbitrate: select it without probing. Its
|
||||
// runner fails closed at EXECUTION time if unusable (refuses to run the
|
||||
// command), and the wrap's runnerFailureSignatures let the consumer
|
||||
// classify that as a sandbox failure — never a silent unconfined run,
|
||||
// never a plain task failure.
|
||||
if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }
|
||||
for (const runner of chain) {
|
||||
const enforcement = this.probeRunner(runner)
|
||||
if (enforcement !== 'unusable') return { runner, enforcement }
|
||||
}
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
/** One rung's functional probe (each at most once, via the chain walk). */
|
||||
private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' {
|
||||
// bwrap's mount profile and Seatbelt's deny-file-write* profile govern
|
||||
// every promised file effect by construction, so their passing probes
|
||||
// are always full enforcement; only the Landlock launcher's probe report
|
||||
// distinguishes full from per-ABI-partial.
|
||||
switch (runner) {
|
||||
case 'bwrap': {
|
||||
const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs))
|
||||
return probe() ? 'full' : 'unusable'
|
||||
}
|
||||
case 'landlock': {
|
||||
const probe = this.internals.probeLandlock ?? (launcher => defaultProbeLandlock(launcher, { timeoutMs: this.probeTimeoutMs }))
|
||||
return probe(this.landlockLauncher())
|
||||
}
|
||||
case 'seatbelt': {
|
||||
const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs))
|
||||
return probe(this.seatbeltExec()) ? 'full' : 'unusable'
|
||||
}
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
|
||||
/** The Landlock launcher to probe and exec (test seam over the resolved one). */
|
||||
private landlockLauncher(): string {
|
||||
return this.internals.landlockLauncher ?? landlockLauncherPath()
|
||||
}
|
||||
|
||||
/** The `sandbox-exec` executable to probe and exec (test seam over the system one). */
|
||||
private seatbeltExec(): string {
|
||||
return this.internals.seatbeltExec ?? 'sandbox-exec'
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalSandboxProvider
|
||||
@@ -0,0 +1,118 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS bwrap integration proof for the BACKEND: the REAL `bwrap` confining
|
||||
* REAL processes through `confine()` + a direct spawn of the returned argv.
|
||||
* Nothing is forced off: bwrap is the ladder's FIRST rung, so a passing probe
|
||||
* selects it naturally — the wrap shape assertion pins that. Verifies the
|
||||
* WORLD (files exist or don't) and that the kernel's denial text matches the
|
||||
* dialect the wrap advertises; the through-`ctx.bash` consumer proof lives
|
||||
* with `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
|
||||
* host that denies unprivileged user namespaces (the probe is the same
|
||||
* profile the provider enforces, so skip conditions match runtime exactly).
|
||||
*
|
||||
* Workspaces for the workspace-write tests live under the HOME directory on
|
||||
* purpose: bwrap's `/tmp` is an EPHEMERAL mount (the documented
|
||||
* bwrap-profile difference — pinned by its own test below), so only a
|
||||
* workspace OUTSIDE `/tmp` proves the workspace-root rebind itself.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const bwrapUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
const tempFiles: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
for (const file of tempFiles.splice(0)) rmSync(file, { force: true })
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-bwrap-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
return ctx.sandbox as LocalSandboxProvider
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, confined }
|
||||
}
|
||||
|
||||
describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
|
||||
it('the passing probe selects the bwrap rung naturally — first in the ladder, full enforcement, EROFS dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const confined = sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(confined.argv[0]).toBe('bwrap')
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['read-only file system'])
|
||||
})
|
||||
|
||||
it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
// The wrap's denialSignatures must be what the kernel actually prints.
|
||||
expect(result.stderr.toLowerCase()).toContain('read-only file system')
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and the fresh /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf bwrap-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write mounts an EPHEMERAL /tmp: the write succeeds inside, the host /tmp stays untouched', async () => {
|
||||
// The documented bwrap-profile difference: Landlock and Seatbelt grant
|
||||
// the HOST temp areas, bwrap swaps in a fresh tmpfs that dies with the
|
||||
// process — the strongest of the three temp semantics.
|
||||
const workdir = await tempDir(homedir())
|
||||
const target = `/tmp/dsh-bwrap-e2e-ephemeral-${process.pid}.txt`
|
||||
tempFiles.push(target)
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `printf tmp-ok > ${target} && cat ${target}`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('tmp-ok')
|
||||
expect(existsSync(target)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS Landlock integration proof for the BACKEND: the REAL npm-distributed
|
||||
* `landlock-run` launcher (`node-addon-landlock-run`) confining REAL processes through `confine()` + a direct
|
||||
* spawn of the returned argv, with the bwrap rung forced off so the ladder
|
||||
* lands on the launcher. Verifies the WORLD (files exist or don't), not the
|
||||
* wrapper argv alone; the through-`ctx.bash` consumer proof lives with
|
||||
* `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips when the running kernel does not enforce Landlock (or this
|
||||
* platform has no launcher package — the probe cannot pass then). The
|
||||
* binary itself arrives with `pnpm install`, so absence is not a checkout
|
||||
* state.
|
||||
*
|
||||
* Workspaces live under the HOME directory on purpose: `workspace-write`
|
||||
* grants the host `/tmp` wholesale (the documented Landlock-profile
|
||||
* difference), so only a workspace OUTSIDE `/tmp` proves the workspace-root
|
||||
* grant itself.
|
||||
*/
|
||||
|
||||
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })
|
||||
const landlockUsable = probe.status === 0
|
||||
/** The running kernel's enforcement level, from the launcher's probe report — every wrap below must carry exactly this. */
|
||||
const enforcement = /partially enforced/.test(probe.stdout ?? '') ? 'partial' : 'full'
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-landlock-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { probeBwrap: () => false }
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's enforcement. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, enforcement: confined.enforcement }
|
||||
}
|
||||
|
||||
describe.skipIf(!landlockUsable)('sandbox-local: real Landlock confinement through the bundled launcher', () => {
|
||||
it('read-only denies a write — the file must NOT exist, the wrap reports the probed enforcement', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result, enforcement: wrapped } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(wrapped).toBe(enforcement)
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
it('read-only denies a write beneath the host /dev (the /dev/shm tmpfs must stay untouched)', async () => {
|
||||
// The grant is /dev/null the FILE, not /dev the directory: /dev/shm is a
|
||||
// world-writable host tmpfs, and a write landing there would be exactly
|
||||
// the persistent host effect read-only promises never happen.
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const target = `/dev/shm/dsh-landlock-e2e-${process.pid}`
|
||||
const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(existsSync(target)).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf landlock-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write grants the host /tmp (the documented Landlock-profile difference)', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const scratch = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, `printf tmp-ok > ${scratch}/scratch.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(readFileSync(join(scratch, 'scratch.txt'), 'utf8')).toBe('tmp-ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* LocalSandboxProvider tests. No real runner is assumed to exist on the test
|
||||
* host: `runnerCommand` injects deterministic runner argvs, and `internals`
|
||||
* injects probe verdicts plus fake Landlock launcher / `sandbox-exec`
|
||||
* scripts, so profile dialects, ladder selection, verdict caching,
|
||||
* probe-report parsing, per-rung denial signatures, and fail-closed behavior
|
||||
* are all exercised through the real `confine()` path.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import {
|
||||
bwrapProfileArgs,
|
||||
landlockProfileArgs,
|
||||
LocalSandboxProvider,
|
||||
seatbeltProfileArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox-local'
|
||||
import type { Config } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
|
||||
async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, config)
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = internals
|
||||
return { ctx, sandbox }
|
||||
}
|
||||
|
||||
/** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
|
||||
function fakeLauncher(report = 'landlock: fully enforced'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
/** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */
|
||||
function fakeSeatbeltExec(status: number): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-'))
|
||||
const exec = join(dir, 'sandbox-exec')
|
||||
writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 })
|
||||
return exec
|
||||
}
|
||||
|
||||
/** The seatbelt read-only profile — every seatbelt profile starts with these forms. */
|
||||
const SEATBELT_RO_PROFILE = '(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))'
|
||||
|
||||
describe('profile dialects', () => {
|
||||
it('bwrap read-only: whole tree read-only with fresh /dev and /proc, no writable mounts', () => {
|
||||
expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'])
|
||||
})
|
||||
|
||||
it('bwrap workspace-write: adds an ephemeral /tmp and rebinds the workspace root', () => {
|
||||
expect(bwrapProfileArgs(WW)).toEqual([
|
||||
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent',
|
||||
'--tmpfs', '/tmp', '--bind', '/ws', '/ws',
|
||||
])
|
||||
})
|
||||
|
||||
it('landlock read-only: readable tree plus a writable /dev/null, nothing else', () => {
|
||||
// /dev/null specifically, NOT /dev: a whole-/dev grant would let confined
|
||||
// commands write real host paths beneath it (/dev/shm) under read-only.
|
||||
expect(landlockProfileArgs(RO)).toEqual(['--ro', '/', '--rw', '/dev/null'])
|
||||
})
|
||||
|
||||
it('landlock workspace-write: adds the host /tmp and the workspace root', () => {
|
||||
expect(landlockProfileArgs(WW)).toEqual(['--ro', '/', '--rw', '/dev/null', '--rw', '/tmp', '--rw', '/ws'])
|
||||
})
|
||||
|
||||
it('seatbelt read-only: allow-default with every file write denied except the /dev/null literal', () => {
|
||||
expect(seatbeltProfileArgs(RO)).toEqual(['-p', SEATBELT_RO_PROFILE])
|
||||
})
|
||||
|
||||
it('seatbelt workspace-write: one more allow for the canonicalized workspace root, /tmp, and the user temp dir', () => {
|
||||
// `/ws` does not exist, so it is granted as spelled (the canonicalization
|
||||
// fallback); `/tmp` and `os.tmpdir()` exist everywhere and are granted
|
||||
// CANONICALIZED — Seatbelt matches resolved paths (`/tmp` IS
|
||||
// `/private/tmp` on macOS), and both collapse to one grant on hosts
|
||||
// where they resolve to the same directory.
|
||||
const roots = [...new Set(['/ws', realpathSync('/tmp'), realpathSync(tmpdir())])]
|
||||
const allow = `(allow file-write* ${roots.map(root => `(subpath "${root}")`).join(' ')})`
|
||||
expect(seatbeltProfileArgs(WW)).toEqual(['-p', `${SEATBELT_RO_PROFILE} ${allow}`])
|
||||
})
|
||||
|
||||
it('seatbelt workspace-write dedups a workspace root that already IS the temp dir', () => {
|
||||
const profile = seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: tmpdir() })[1] as string
|
||||
const grant = `(subpath "${realpathSync(tmpdir())}")`
|
||||
expect(profile).toContain(grant)
|
||||
expect(profile.split(grant)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runnerCommand config', () => {
|
||||
it('a non-empty runnerCommand skips the chain: runner argv + bwrap-shaped profile + -- + caller argv, asserted full', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'unusable' as const)
|
||||
const probeSeatbelt = vi.fn(() => false)
|
||||
const { sandbox } = await setup({
|
||||
runnerCommand: ['fake-runner', '--flag'],
|
||||
runnerFailureSignatures: ['fake-runner: profile rejected'],
|
||||
}, { probeBwrap, probeLandlock, probeSeatbelt })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
|
||||
expect(confined).toEqual({
|
||||
argv: ['fake-runner', '--flag', ...bwrapProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
// An operator runner's kernel mechanism is unknown: both Linux
|
||||
// file-denial dialects, never bare EPERM.
|
||||
denialSignatures: ['read-only file system', 'permission denied'],
|
||||
// The runner's own dialect is unknown, but the consumer re-joins the
|
||||
// wrap through an outer `bash -c 'exec …'` — a missing or
|
||||
// unexecutable runner fails with the OUTER shell's argv0-scoped
|
||||
// shapes, and those classify as sandbox failures like any rung.
|
||||
runnerFailureSignatures: [
|
||||
'fake-runner: profile rejected',
|
||||
'exec: fake-runner: not found',
|
||||
'fake-runner: No such file or directory',
|
||||
'fake-runner: Permission denied',
|
||||
],
|
||||
})
|
||||
expect(probeBwrap).not.toHaveBeenCalled()
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an EMPTY runnerCommand means unconfigured: the platform chain still gates the wrap', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const { sandbox } = await setup({ runnerCommand: [] }, { platform: 'linux', probeBwrap, probeLandlock: () => 'unusable' })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires an operator-owned failure dialect for every configured runner', async () => {
|
||||
await expect(setup({ runnerCommand: ['fake-runner'] })).rejects.toThrow(
|
||||
'runnerCommand requires at least one runnerFailureSignatures entry',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects runner failure signatures when no custom runner consumes them', async () => {
|
||||
await expect(setup({ runnerFailureSignatures: ['profile rejected'] })).rejects.toThrow(
|
||||
'runnerFailureSignatures requires runnerCommand',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects blank configured-runner failure signatures', async () => {
|
||||
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow(
|
||||
'runnerFailureSignatures entries must be non-empty',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the platform chains', () => {
|
||||
it('linux probes bwrap first: a passing probe wraps with the bwrap dialect at full enforcement', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['read-only file system'],
|
||||
runnerFailureSignatures: ['bwrap: '],
|
||||
})
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('linux falls back to the launcher when the bwrap probe fails, speaking the landlock dialect', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const launcher = fakeLauncher()
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock, landlockLauncher: launcher })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], WW)
|
||||
expect(confined).toEqual({
|
||||
argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['permission denied'],
|
||||
runnerFailureSignatures: ['landlock-run: '],
|
||||
})
|
||||
expect(probeLandlock).toHaveBeenCalledWith(launcher)
|
||||
})
|
||||
|
||||
it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => {
|
||||
// The safety property moves to execution time: an unusable sandbox-exec
|
||||
// refuses to run the command, and the wrap's runnerFailureSignatures let
|
||||
// the consumer classify that as a sandbox failure, not a task failure.
|
||||
const probeSeatbelt = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt })
|
||||
const confined = sandbox.confine(['bash', '-c', 'echo hi'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
})
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a platform with no chain fails closed without a single probe: the command never runs', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const probeLandlock = vi.fn(() => 'full' as const)
|
||||
const probeSeatbelt = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'freebsd', probeBwrap, probeLandlock, probeSeatbelt })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
expect(probeBwrap).not.toHaveBeenCalled()
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => {
|
||||
// The slot exists so Windows support is an additive fill-in (chain entry
|
||||
// + runner union member), never a redesign — and reserving it must not
|
||||
// weaken the fail-closed end in the meantime.
|
||||
const { sandbox } = await setup({}, { platform: 'win32' })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap })
|
||||
sandbox.confine(['true'], RO)
|
||||
sandbox.confine(['true'], WW)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the unavailable verdict is cached too, and the error is structured', async () => {
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const probeLandlock = vi.fn(() => 'unusable' as const)
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap, probeLandlock })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(SandboxUnavailableError)
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
expect(probeLandlock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a multi-rung chain probes a seatbelt rung like any other (the walk, not the platform table, decides)', async () => {
|
||||
// The product chains reach seatbelt only as darwin's sole (unprobed)
|
||||
// candidate; the chain seam exercises the probing path it would take in
|
||||
// a grown chain, keeping the default seatbelt probe honest.
|
||||
const exec = fakeSeatbeltExec(0)
|
||||
const probeBwrap = vi.fn(() => false)
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap, seatbeltExec: exec })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv[0]).toBe(exec)
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(probeBwrap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a rogue chain entry throws via the probe walk\'s exhaustiveness guard (closed union)', async () => {
|
||||
// Same convention as the wrap switch below: the union is closed, so a
|
||||
// runner added later fails to compile at the probe switch instead of
|
||||
// silently selecting without a probe. Only a cast can reach the guard.
|
||||
const { sandbox } = await setup({}, { chain: ['chroot', 'bwrap'] as unknown as readonly ['bwrap'] })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => {
|
||||
// The wrap switches on the chain verdict's runner tag and ends with
|
||||
// assertNever: a rogue tag (only reachable by a cast — the union is
|
||||
// closed and chainVerdict writes only its own literals) must throw, so a
|
||||
// runner added later fails to compile at the switch instead of silently
|
||||
// wrapping with another runner's dialect.
|
||||
const { sandbox } = await setup()
|
||||
;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' }
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
|
||||
})
|
||||
|
||||
it('runs the real default probes on the linux chain when none are injected (usable here or fail closed there)', async () => {
|
||||
// Pinning the platform (not the probes) makes the REAL defaultProbeBwrap
|
||||
// spawn run on every host: bwrap answers on a Linux box, ENOENT reads as
|
||||
// an unusable rung anywhere else — either way the walk is genuine.
|
||||
const { sandbox } = await setup({}, { platform: 'linux' })
|
||||
const verdict = (() => {
|
||||
try {
|
||||
sandbox.confine(['true'], RO)
|
||||
return 'usable'
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return 'unavailable'
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
expect(['usable', 'unavailable']).toContain(verdict)
|
||||
})
|
||||
|
||||
it('walks the real platform chain when nothing is injected (usable here or fail closed there)', async () => {
|
||||
const { sandbox } = await setup({}, {})
|
||||
const verdict = (() => {
|
||||
try {
|
||||
sandbox.confine(['true'], RO)
|
||||
return 'usable'
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return 'unavailable'
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
expect(['usable', 'unavailable']).toContain(verdict)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default landlock probe (launcher CLI contract)', () => {
|
||||
it('parses a fully-enforced probe report as full enforcement', async () => {
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: fakeLauncher() })
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
})
|
||||
|
||||
it('parses a partially-enforced (older-ABI) probe report as partial enforcement', async () => {
|
||||
const launcher = fakeLauncher('landlock: partially enforced (older ABI)')
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(sandbox.confine(['true'], RO).enforcement).toBe('partial')
|
||||
})
|
||||
|
||||
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 })
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeTimeoutMs config', () => {
|
||||
it('rejects 0 at construction: Node treats a 0 spawnSync timeout as UNBOUNDED, the opposite of the field', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(LocalSandboxProvider, { probeTimeoutMs: 0 }))
|
||||
.rejects.toThrow(/probeTimeoutMs must be a positive finite number/)
|
||||
})
|
||||
|
||||
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
|
||||
// The same sleeping launcher passes under the default 5000ms budget and
|
||||
// fails under a 250ms one — the config demonstrably reaches spawnSync.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
|
||||
|
||||
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
|
||||
const impatient = await setup(
|
||||
{ probeTimeoutMs: 250 },
|
||||
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
|
||||
)
|
||||
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
// The product chains reach seatbelt only unprobed (darwin's sole
|
||||
// candidate), so the default probe's contract is pinned through the chain
|
||||
// seam: a grown chain must probe it like any other rung.
|
||||
it('selects the rung when the executable applies the read-only profile and exits 0', async () => {
|
||||
const exec = fakeSeatbeltExec(0)
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: exec })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined).toEqual({
|
||||
argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a failing executable as unusable: the chain ends and fails closed', async () => {
|
||||
const { sandbox } = await setup({}, { chain: ['bwrap', 'seatbelt'], probeBwrap: () => false, seatbeltExec: fakeSeatbeltExec(1) })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { accessSync, constants, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* KEYLESS publish-path rehearsal for this package's own distribution: the
|
||||
* provider must work from its PACKED tarball plus its REGISTRY launcher
|
||||
* dependency, not the git checkout. `pnpm pack` produces the EXACT bytes
|
||||
* `pnpm publish` would upload; this suite packs the workspace closure
|
||||
* (`dsh-sandbox-local` + its `@deepseek-ai` peers), installs the tarballs
|
||||
* into a throwaway consumer OUTSIDE the repo — npm resolving the
|
||||
* `node-addon-landlock-run` dependency (and its os/cpu-selected platform
|
||||
* package) from the public registry, the real consumer path — and drives
|
||||
* the INSTALLED packages under plain `node`: no tsx, no tsconfig paths, no
|
||||
* workspace resolution, so a `files`-list omission, a broken launcher
|
||||
* dependency, or a mode-stripped binary fails here instead of at the first
|
||||
* real install.
|
||||
*
|
||||
* World-proofs: the registry-installed launcher carries this host's ELF
|
||||
* architecture and IS executable (a tarball that loses the mode bit would
|
||||
* otherwise masquerade as a non-enforcing kernel — the fail-closed branch
|
||||
* below must never absorb that), and the installed provider confines a real
|
||||
* process THROUGH it (bwrap forced off) — or fails closed when the running
|
||||
* kernel does not enforce Landlock, which is itself the installed
|
||||
* fail-closed contract. Byte provenance of the launcher is the
|
||||
* `node-addon-landlock-run` repository's own release-pipeline concern.
|
||||
*
|
||||
* Self-skips off Linux or when the built `lib/` is absent (run
|
||||
* `pnpm run build` first — CI's landlock legs do).
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
|
||||
/** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */
|
||||
const WORKSPACE_CLOSURE = [
|
||||
'packages/sandbox/sandbox-local',
|
||||
'packages/sandbox/sandbox',
|
||||
'packages/llm/llm',
|
||||
'packages/util/brand',
|
||||
]
|
||||
|
||||
/** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */
|
||||
const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64']
|
||||
|
||||
const packable = process.platform === 'linux'
|
||||
&& E_MACHINE !== undefined
|
||||
&& existsSync(join(packageDir, 'lib', 'index.js'))
|
||||
|
||||
let consumerDir = ''
|
||||
let workDir = ''
|
||||
/** The consumer script's JSON verdict (see its source below). */
|
||||
let verdict: {
|
||||
launcher: string
|
||||
launcherExists: boolean
|
||||
enforcing: boolean
|
||||
wrapArgv0?: string
|
||||
enforcement?: string
|
||||
exitCode?: number | null
|
||||
stderrHasDialect?: boolean
|
||||
confineOutcome?: string
|
||||
} = { launcher: '', launcherExists: false, enforcing: false }
|
||||
|
||||
describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-path rehearsal)', () => {
|
||||
beforeAll(async () => {
|
||||
const packDest = mkdtempSync(join(tmpdir(), 'dsh-pack-'))
|
||||
consumerDir = mkdtempSync(join(tmpdir(), 'dsh-packed-consumer-'))
|
||||
workDir = mkdtempSync(join(tmpdir(), 'dsh-packed-work-'))
|
||||
|
||||
// Pack each closure member with the exact bytes publish would upload.
|
||||
const tarballs: string[] = []
|
||||
for (const pkg of WORKSPACE_CLOSURE) {
|
||||
const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], {
|
||||
cwd: join(repoRoot, pkg),
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
})
|
||||
expect(pack.status, `pnpm pack failed for ${pkg}:\n${pack.stdout}\n${pack.stderr}`).toBe(0)
|
||||
const lines = pack.stdout.trim().split('\n')
|
||||
tarballs.push(lines[lines.length - 1] as string)
|
||||
}
|
||||
|
||||
// A real consumer: plain ESM project, tarballs installed by npm — the
|
||||
// peer ranges (^0.0.1) resolve to the tarball versions, cordis pins to
|
||||
// the peer range's rc, and `node-addon-landlock-run` (with its
|
||||
// os/cpu-selected platform package, an OPTIONAL dependency of the entry
|
||||
// — so no `--omit=optional` here) comes from the public registry.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], {
|
||||
cwd: consumerDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
})
|
||||
expect(install.status, `npm install failed:\n${install.stdout}\n${install.stderr}`).toBe(0)
|
||||
|
||||
// The consumer script runs under PLAIN node against the installed
|
||||
// packages and reports a JSON verdict; every assertion happens back in
|
||||
// the test. bwrap is forced off so the wrap must select the INSTALLED
|
||||
// launcher; a non-enforcing kernel must surface the fail-closed error.
|
||||
writeFileSync(join(consumerDir, 'consumer.mjs'), `
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox
|
||||
sandbox.internals = { probeBwrap: () => false }
|
||||
const launcher = launcherPath()
|
||||
const probe = spawnSync(launcher, ['--probe'], { encoding: 'utf8', timeout: 5000 })
|
||||
const out = { launcher, launcherExists: existsSync(launcher), enforcing: probe.status === 0 }
|
||||
const workdir = process.argv[2]
|
||||
if (out.enforcing) {
|
||||
const confined = sandbox.confine(['bash', '-c', \`echo hi > \${workdir}/denied.txt\`], { mode: 'read-only', workspaceRoot: workdir })
|
||||
out.wrapArgv0 = confined.argv[0]
|
||||
out.enforcement = confined.enforcement
|
||||
const run = spawnSync(confined.argv[0], confined.argv.slice(1), { encoding: 'utf8', timeout: 30000 })
|
||||
out.exitCode = run.status
|
||||
out.stderrHasDialect = /permission denied/i.test(run.stderr)
|
||||
} else {
|
||||
try {
|
||||
sandbox.confine(['true'], { mode: 'read-only', workspaceRoot: workdir })
|
||||
out.confineOutcome = 'wrapped'
|
||||
} catch (error) {
|
||||
out.confineOutcome = error?.code === 'SANDBOX_UNAVAILABLE' ? 'fail-closed' : String(error)
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(out))
|
||||
`)
|
||||
const consumer = spawnSync('node', ['consumer.mjs', workDir], { cwd: consumerDir, encoding: 'utf8', timeout: 60_000 })
|
||||
expect(consumer.status, `consumer script failed:\n${consumer.stdout}\n${consumer.stderr}`).toBe(0)
|
||||
verdict = JSON.parse(consumer.stdout.trim().split('\n').pop() as string) as typeof verdict
|
||||
}, 480_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([consumerDir, workDir].filter(Boolean).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('installs the registry launcher for this host: present, EXECUTABLE, right ELF arch', () => {
|
||||
const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run')
|
||||
expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true)
|
||||
// A tarball or extraction step that strips the mode bit would leave the
|
||||
// probe failing exactly like a non-enforcing kernel — assert it apart.
|
||||
expect(() => { accessSync(installed, constants.X_OK) }, 'installed launcher is not executable').not.toThrow()
|
||||
expect(readFileSync(installed).readUInt16LE(18), 'ELF e_machine').toBe(E_MACHINE)
|
||||
})
|
||||
|
||||
it('the installed provider resolves the launcher INSIDE the consumer node_modules platform package', () => {
|
||||
expect(verdict.launcher)
|
||||
.toBe(join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run'))
|
||||
})
|
||||
|
||||
it('confines through the installed launcher (enforcing kernel) or fails closed (non-enforcing) — never unconfined', async () => {
|
||||
// Fail-closed is only the acceptable outcome when the installed binary
|
||||
// IS present and executable and the kernel merely does not enforce —
|
||||
// the first test pins that apart, so nothing hides behind this branch.
|
||||
expect(verdict.launcherExists, 'installed launcher missing').toBe(true)
|
||||
if (verdict.enforcing) {
|
||||
expect(verdict.wrapArgv0).toBe(verdict.launcher)
|
||||
expect(['full', 'partial']).toContain(verdict.enforcement)
|
||||
expect(verdict.exitCode).not.toBe(0)
|
||||
expect(verdict.stderrHasDialect, 'kernel denial text must match the advertised dialect').toBe(true)
|
||||
expect(existsSync(join(workDir, 'denied.txt'))).toBe(false)
|
||||
} else {
|
||||
expect(verdict.confineOutcome).toBe('fail-closed')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/**
|
||||
* KEYLESS Seatbelt integration proof for the BACKEND: the REAL macOS
|
||||
* `sandbox-exec` confining REAL processes through `confine()` + a direct
|
||||
* spawn of the returned argv, with the Linux rungs forced off so the ladder
|
||||
* lands on Seatbelt. Verifies the WORLD (files exist or don't) and that the
|
||||
* kernel's denial text matches the dialect the wrap advertises; the
|
||||
* through-`ctx.bash` consumer proof lives with `@deepseek-ai/dsh-bash-sandbox`.
|
||||
*
|
||||
* Self-skips wherever the functional probe fails — every non-macOS host, or
|
||||
* a macOS whose `sandbox-exec` refuses the profile.
|
||||
*
|
||||
* Workspaces for the workspace-write tests live under the HOME directory on
|
||||
* purpose: `workspace-write` grants `/tmp` and the per-user temp dir
|
||||
* wholesale (the documented Seatbelt-profile temp areas), so only a
|
||||
* workspace OUTSIDE both proves the workspace-root grant itself.
|
||||
*/
|
||||
|
||||
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })
|
||||
const seatbeltUsable = probe.status === 0
|
||||
|
||||
let ctx: Context | undefined
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function tempDir(base: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(base, 'dsh-seatbelt-e2e-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function provider(): Promise<LocalSandboxProvider> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/** Confine a shell command under `policy` and run it for real; returns the spawn result and the wrap's facts. */
|
||||
function runConfined(sandbox: LocalSandboxProvider, command: string, policy: SandboxPolicy) {
|
||||
const confined = sandbox.confine(['bash', '-c', command], policy)
|
||||
const result = spawnSync(confined.argv[0] as string, confined.argv.slice(1), { timeout: 30_000, encoding: 'utf8' })
|
||||
return { result, confined }
|
||||
}
|
||||
|
||||
describe.skipIf(!seatbeltUsable)('sandbox-local: real Seatbelt confinement through sandbox-exec', () => {
|
||||
it('read-only denies a write — the file must NOT exist, and the kernel speaks the advertised dialect', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result, confined } = runConfined(sandbox, `echo hi > ${workdir}/denied.txt`, { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(confined.enforcement).toBe('full')
|
||||
// The wrap's denialSignatures must be what the kernel actually prints.
|
||||
expect(result.stderr.toLowerCase()).toContain('operation not permitted')
|
||||
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('read-only keeps the tree readable/executable and /dev/null writable', async () => {
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(sandbox, 'ls / > /dev/null && echo dev-ok', { mode: 'read-only', workspaceRoot: workdir })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('dev-ok\n')
|
||||
})
|
||||
|
||||
it('read-only grants no temp area: a write under the user temp dir is denied too', async () => {
|
||||
// The per-user darwin temp dir is a workspace-write grant, not a
|
||||
// read-only one — under read-only the only write-shaped path is /dev/null.
|
||||
const workdir = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const target = join(workdir, 'tmp-denied.txt')
|
||||
const { result } = runConfined(sandbox, `echo hi > ${target}`, { mode: 'read-only', workspaceRoot: await tempDir(homedir()) })
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(existsSync(target)).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const sandbox = await provider()
|
||||
|
||||
const inside = runConfined(sandbox, `printf seatbelt-ok > ${workdir}/allowed.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(inside.result.status).toBe(0)
|
||||
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
|
||||
|
||||
const denied = runConfined(sandbox, `echo hi > ${outside}/denied.txt`, { mode: 'workspace-write', workspaceRoot: workdir })
|
||||
expect(denied.result.status).not.toBe(0)
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write grants /tmp and the user temp dir (the documented Seatbelt-profile temp areas)', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const hostTmp = await tempDir('/tmp')
|
||||
const userTmp = await tempDir(tmpdir())
|
||||
const sandbox = await provider()
|
||||
const { result } = runConfined(
|
||||
sandbox,
|
||||
`printf tmp-ok > ${hostTmp}/scratch.txt && printf user-tmp-ok > ${userTmp}/scratch.txt`,
|
||||
{ mode: 'workspace-write', workspaceRoot: workdir },
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
expect(readFileSync(join(hostTmp, 'scratch.txt'), 'utf8')).toBe('tmp-ok')
|
||||
expect(readFileSync(join(userTmp, 'scratch.txt'), 'utf8')).toBe('user-tmp-ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# @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](../../../docs/rfc/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 RFC](../../../docs/rfc/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]`).
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox",
|
||||
"description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The process-sandbox seam (`ctx.sandbox`): an abstract service defining WHAT
|
||||
* platform confinement does — wrap a subprocess argv so it executes under a
|
||||
* file-effect policy — without saying HOW. Implementations subclass
|
||||
* {@link SandboxProvider} and register as the `sandbox` service;
|
||||
* `@deepseek-ai/dsh-sandbox-local` (per-platform chains: Linux `bwrap` then the
|
||||
* npm-distributed `landlock-run` launcher, macOS `sandbox-exec`/Seatbelt) is
|
||||
* the first.
|
||||
* Consumers hand over the exact argv they are about to spawn
|
||||
* (`@deepseek-ai/dsh-bash-sandbox` wraps `['bash', '-c', command]`; a
|
||||
* subagent backend wraps its child-agent argv) and spawn the returned argv
|
||||
* instead.
|
||||
*
|
||||
* The seam confines SAME-WORLD subprocesses only: a backend shares the
|
||||
* host's filesystem and kernel, and the policy's `workspaceRoot` names a
|
||||
* real host path. Containers, microVMs, and remote executors are NOT
|
||||
* backends of this seam — they are sibling implementations of whole
|
||||
* capability seams (`ctx.bash`, `ctx.fs`), deployed as environment-coherent
|
||||
* groups; the boundary is recorded in
|
||||
* docs/rfc/implemented/feature/2026-07-06-sandbox.md.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* File-effect policy a sandbox backend enforces on confined processes.
|
||||
*
|
||||
* - `read-only` — the process cannot write the filesystem anywhere; a
|
||||
* write-shaped `/dev/null` sink stays available so `>/dev/null` redirects
|
||||
* keep working (HOW is the backend's choice: bwrap mounts a fresh `/dev`,
|
||||
* the Landlock launcher and Seatbelt grant the single `/dev/null` node).
|
||||
* - `workspace-write` — writes are allowed only under the policy's
|
||||
* workspace root and `/tmp`; everything else stays read-only. Which `/tmp`
|
||||
* is backend-specific — an ephemeral mount under bwrap, the HOST `/tmp`
|
||||
* under the Landlock launcher, the host `/private/tmp` plus the per-user
|
||||
* darwin temp dir under Seatbelt: the seam promises the write boundary,
|
||||
* not the mount's nature.
|
||||
* - `danger-full-access` — no confinement; a consumer configured with it
|
||||
* spawns its argv unwrapped and never calls the provider.
|
||||
*
|
||||
* The mode governs FILE effects only: network and process visibility are not
|
||||
* restricted (a backend that cannot honestly enforce them must not pretend
|
||||
* to). How completely the file effects themselves are enforced is likewise a
|
||||
* reported fact, not an assumption — see {@link SandboxEnforcement}.
|
||||
*/
|
||||
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'>
|
||||
|
||||
/**
|
||||
* How completely the selected backend enforces a confined mode's file
|
||||
* effects.
|
||||
*
|
||||
* - `full` — every file effect the mode promises to block is governed: the
|
||||
* `bwrap` mount profile, a Landlock kernel enforcing the launcher's whole
|
||||
* ruleset, or an operator-configured runner (configuring one asserts full
|
||||
* enforcement along with existence).
|
||||
* - `partial` — the backend is active but the kernel governs only the subset
|
||||
* of accesses its ABI knows (an older Landlock ABI: path-based truncate is
|
||||
* ungoverned before ABI v3), so a file effect the mode promises to block
|
||||
* may still land. A caller that needs the mode's promise to be absolute
|
||||
* must treat `partial` as outside that promise.
|
||||
*/
|
||||
export type SandboxEnforcement = 'full' | 'partial'
|
||||
|
||||
/**
|
||||
* What one confined execution is allowed to touch — carried PER CALL, not
|
||||
* 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.
|
||||
*/
|
||||
export interface SandboxPolicy {
|
||||
/** The file-effect mode this execution runs under. */
|
||||
mode: ConfinedSandboxMode
|
||||
/** Absolute root directory `workspace-write` may write under. */
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SandboxProvider.confine} result: the argv to spawn in place of
|
||||
* the caller's own, plus the enforcement completeness the selected backend
|
||||
* achieves for it.
|
||||
*/
|
||||
export interface ConfinedArgv {
|
||||
/** The wrapped argv (runner, profile, separator, then the caller's argv). */
|
||||
argv: string[]
|
||||
/** How completely the selected backend enforces the policy's file effects. */
|
||||
enforcement: SandboxEnforcement
|
||||
/**
|
||||
* The selected backend's denial DIALECT: the case-insensitive stderr
|
||||
* substrings a file effect denied by THIS backend produces (EROFS text
|
||||
* under bwrap's read-only binds, EACCES under Landlock, EPERM under
|
||||
* Seatbelt). A consumer that infers denials from a failed run's stderr
|
||||
* matches against exactly these rather than a cross-backend union — the
|
||||
* union claims denials a given backend never produces.
|
||||
*/
|
||||
denialSignatures: readonly string[]
|
||||
/**
|
||||
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr
|
||||
* substrings produced when the sandbox binary is missing, refuses its
|
||||
* profile, or fails closed before exec'ing the command (`bwrap: `,
|
||||
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
|
||||
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
|
||||
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
|
||||
* (the sandbox working as designed); a runner failure means the command
|
||||
* NEVER RAN and must surface as a sandbox failure, not a task failure —
|
||||
* consumers check these signatures FIRST (a runner's own error text may
|
||||
* contain denial words, e.g. an unopenable grant root reporting
|
||||
* `Permission denied`).
|
||||
*/
|
||||
runnerFailureSignatures: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Error `code` carried by the infrastructure error a provider throws when a
|
||||
* confined policy is requested but no backend is available or usable on this
|
||||
* host: confinement FAILS CLOSED (refuses to run) rather than silently
|
||||
* executing unconfined. Thrown as a `HarnessError`, it reaches the model
|
||||
* through the structured `{ name, code }` error channel on `tool/result`, so
|
||||
* callers can distinguish "the sandbox is missing" from a failing command.
|
||||
*/
|
||||
export const SANDBOX_UNAVAILABLE = 'SANDBOX_UNAVAILABLE'
|
||||
|
||||
/**
|
||||
* Thrown by {@link SandboxProvider.confine} when a confined policy is
|
||||
* requested but no backend is usable on this host: confinement fails closed.
|
||||
* Carries the {@link SANDBOX_UNAVAILABLE} code through the structured
|
||||
* `{ name, code }` error channel.
|
||||
*/
|
||||
export class SandboxUnavailableError extends HarnessError {
|
||||
constructor(mode: ConfinedSandboxMode, detail?: string) {
|
||||
super(
|
||||
`sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; `
|
||||
+ 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing '
|
||||
+ 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement '
|
||||
+ 'backend yet — or switch the consumer to danger-full-access.'
|
||||
+ (detail === undefined ? '' : ` Runner failure: ${detail}`),
|
||||
SANDBOX_UNAVAILABLE,
|
||||
)
|
||||
this.name = 'SandboxUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sandbox: SandboxProvider
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract process-sandbox service. Subclass, implement {@link confine}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.sandbox` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link confine} either returns an argv whose runner ENFORCES the policy
|
||||
* or fails closed — at `confine` time with {@link SandboxUnavailableError}
|
||||
* (no backend for this host), or at EXECUTION time by the runner itself
|
||||
* refusing to run the command (exiting without exec'ing it, identified by
|
||||
* {@link ConfinedArgv.runnerFailureSignatures}). A silent unconfined
|
||||
* passthrough is never a legal outcome on either path.
|
||||
* - Probing exists to ARBITRATE between multiple candidate backends and may
|
||||
* be skipped when a platform has exactly one: the sole candidate is
|
||||
* selected directly and the runner's exec-time fail-closed refusal carries
|
||||
* the safety property. When probing does run, it is functional (actually
|
||||
* enforcing a profile, not a version check), at most once per provider
|
||||
* lifetime; `confine` itself spawns nothing beyond that one-time probing.
|
||||
* - The returned {@link ConfinedArgv.enforcement} states the backend's
|
||||
* actual completeness for THIS host; `partial` is reported, never silently
|
||||
* upgraded to `full`.
|
||||
*/
|
||||
export abstract class SandboxProvider extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sandbox')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `argv` so it executes confined under `policy` on this host; the
|
||||
* caller spawns the returned argv in place of its own.
|
||||
* @param argv - the exact argv the caller is about to spawn (program plus
|
||||
* arguments), NOT a shell string — a shell-shaped consumer passes
|
||||
* `['bash', '-c', command]`.
|
||||
* @param policy - the file-effect policy this execution runs under,
|
||||
* carried per call (see {@link SandboxPolicy}).
|
||||
* @returns the argv to spawn instead, plus the enforcement completeness
|
||||
* the selected backend achieves for it.
|
||||
*/
|
||||
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
|
||||
}
|
||||
|
||||
export default SandboxProvider
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Vocabulary-contract tests for the sandbox seam: the fail-closed error's
|
||||
* structured identity is what tool results and consumers key on, so its
|
||||
* shape is pinned here, next to the vocabulary that owns it. Provider
|
||||
* behavior is each implementation's suite (`dsh-sandbox-local`); consumer
|
||||
* behavior is each consumer's (`dsh-bash-sandbox`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('SandboxUnavailableError', () => {
|
||||
it('carries the structured { name, code } identity consumers key on', () => {
|
||||
const error = new SandboxUnavailableError('read-only')
|
||||
expect(error.name).toBe('SandboxUnavailableError')
|
||||
expect(error.code).toBe(SANDBOX_UNAVAILABLE)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('names the refused mode and the operator escape hatches in its message', () => {
|
||||
const error = new SandboxUnavailableError('workspace-write')
|
||||
expect(error.message).toContain('"workspace-write"')
|
||||
expect(error.message).toContain('danger-full-access')
|
||||
expect(error.message).not.toContain('Runner failure')
|
||||
})
|
||||
|
||||
it('carries the runner detail when the failure is discovered at execution time', () => {
|
||||
// The late twin of the confine-time throw: an unprobed sole candidate
|
||||
// that fails closed at exec surfaces the SAME error, with the runner's
|
||||
// own first stderr line as the cause.
|
||||
const error = new SandboxUnavailableError('read-only', 'landlock-run: landlock is not enforced by this kernel')
|
||||
expect(error.code).toBe(SANDBOX_UNAVAILABLE)
|
||||
expect(error.message).toContain('Runner failure: landlock-run: landlock is not enforced by this kernel')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# skill/ - skill capability family
|
||||
|
||||
The canonical three-package capability seam for reusable agent instructions: a provider registry, a local implementation, and the model-facing catalog/loader consumer. All are **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` |
|
||||
| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) |
|
||||
| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md).
|
||||
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-skill-local
|
||||
|
||||
Local filesystem provider for the `ctx.skills` registry.
|
||||
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
|
||||
## Plugin
|
||||
|
||||
Requires `ctx.skills` (`inject: ['skills']`).
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
|
||||
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
|
||||
|
||||
## Discovery
|
||||
|
||||
Default roots are resolved in this provider's rank order:
|
||||
|
||||
| Rank | Source | Path |
|
||||
|---|---|---|
|
||||
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
|
||||
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
|
||||
| 300 | `custom` | `Config.customSkillDirs` |
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. DeepSeek Harness no longer ships built-in system skills from this provider; additional built-ins can be supplied later by another provider.
|
||||
|
||||
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
|
||||
|
||||
## Skill Format
|
||||
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill-local",
|
||||
"description": "Local filesystem skill provider for the DeepSeek Harness",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Local filesystem skill provider.
|
||||
*
|
||||
* This package is one implementation of the `ctx.skills` provider registry. It
|
||||
* discovers directory-bundle and flat Markdown skills from project, custom, and
|
||||
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
|
||||
* filesystem service is present.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill-local
|
||||
*/
|
||||
|
||||
import { access, readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
isSkillName,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillSource,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
const PROJECT_DSH_RANK = 100
|
||||
const PROJECT_AGENTS_RANK = 200
|
||||
const CUSTOM_RANK = 300
|
||||
const USER_DSH_RANK = 400
|
||||
const USER_AGENTS_RANK = 500
|
||||
|
||||
export const name = 'skill-local'
|
||||
export const inject = ['skills']
|
||||
|
||||
/** Local filesystem skill provider configuration. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
||||
agentsHome?: string
|
||||
/** Additional skill roots scanned after project roots and before user roots. */
|
||||
customSkillDirs?: string[]
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
customSkillDirs: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
interface SkillRoot {
|
||||
path: string
|
||||
source: SkillSource
|
||||
rank: number
|
||||
skipSystem?: boolean
|
||||
}
|
||||
|
||||
interface SkillRootEntry {
|
||||
name: string
|
||||
type: 'directory' | 'file' | 'other'
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
|
||||
interface LocalLocator {
|
||||
path: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
/** Register the local filesystem skill provider on `ctx.skills`. */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const provider = new LocalSkillProvider(ctx, config)
|
||||
ctx.skills.registerProvider(provider)
|
||||
}
|
||||
|
||||
/** Provider that maps local project/user skill roots into `ctx.skills`. */
|
||||
export class LocalSkillProvider implements SkillProvider {
|
||||
readonly name = 'local'
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local skill summaries for a cwd-sensitive workspace.
|
||||
* @param options - lookup options; `cwd` selects the project roots to scan.
|
||||
* @returns local provider candidates with stable root ranks.
|
||||
*/
|
||||
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
|
||||
const roots = await this.roots(options.cwd)
|
||||
const candidates: SkillCandidate[] = []
|
||||
for (const root of roots) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) {
|
||||
candidates.push(skill)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a complete local skill body from the candidate's file locator.
|
||||
* @param candidate - the winning candidate returned by this provider.
|
||||
* @param options - lookup options whose signal cancels filesystem reads.
|
||||
* @returns the full local skill, or `undefined` if the file disappeared.
|
||||
*/
|
||||
async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as LocalLocator
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
|
||||
if (parsed === undefined) return undefined
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
source: candidate.source,
|
||||
provider: this.name,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
path: locator.path,
|
||||
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
|
||||
content: parsed.content,
|
||||
}
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
||||
const roots: SkillRoot[] = []
|
||||
if (cwd !== undefined) {
|
||||
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
|
||||
roots.push(
|
||||
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK },
|
||||
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK },
|
||||
)
|
||||
}
|
||||
roots.push(
|
||||
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
)
|
||||
return roots
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
|
||||
const skills: SkillCandidate[] = []
|
||||
const entries = await listSkillRootEntries(root, ctx)
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (root.skipSystem && entry.name === '.system') continue
|
||||
const locator = entry.type === 'directory'
|
||||
? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
|
||||
: entry.type === 'file' && entry.name.endsWith('.md')
|
||||
? { path: entry.path, directory: root.path }
|
||||
: undefined
|
||||
if (locator === undefined) continue
|
||||
const parsed = await parseSkillFile(locator.path, ctx)
|
||||
if (parsed === undefined) continue
|
||||
skills.push({
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
provider: 'local',
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
locator,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
path: locator.path,
|
||||
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
|
||||
})
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
return await listSkillRootEntriesFromNode(root, ctx)
|
||||
}
|
||||
|
||||
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
|
||||
// Skill roots are optional; an absent or unlistable root contributes no skills.
|
||||
const entries = await fsListDir(fs, root.path).catch(() => undefined)
|
||||
return entries === undefined ? [] : entries.map(entryFromFs)
|
||||
}
|
||||
|
||||
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
|
||||
const target = await fs.resolve(path)
|
||||
return await fs.listDir(target)
|
||||
}
|
||||
|
||||
function entryFromFs(entry: FsDirEntry): SkillRootEntry {
|
||||
return { name: entry.name, type: entry.type, path: entry.target.displayPath }
|
||||
}
|
||||
|
||||
async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch {
|
||||
// Missing or unreadable local skill roots are expected in most deployments.
|
||||
return []
|
||||
}
|
||||
|
||||
const result: SkillRootEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const path = join(root.path, entry.name)
|
||||
const type = await nodeEntryKind(path, entry, ctx)
|
||||
result.push({ name: entry.name, type: type ?? 'other', path })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseFrontmatter(raw)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
if (!parsed) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
|
||||
return undefined
|
||||
}
|
||||
const name = stringField(parsed.data, 'name')
|
||||
const description = stringField(parsed.data, 'description')
|
||||
if (name === undefined || description === undefined) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
|
||||
return undefined
|
||||
}
|
||||
if (!isSkillName(name)) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...optionalMetadata(parsed.data),
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
|
||||
}
|
||||
try {
|
||||
return await readFile(path, { encoding: 'utf8', signal })
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
// A missing or temporarily inaccessible skill file is not fatal to discovery.
|
||||
signal?.throwIfAborted()
|
||||
const target = await fs.resolve(path).catch(() => undefined)
|
||||
signal?.throwIfAborted()
|
||||
if (target === undefined) return undefined
|
||||
let info
|
||||
try {
|
||||
info = await fs.stat(target, signal)
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
if (info === undefined || info.type !== 'file') return undefined
|
||||
try {
|
||||
return await fs.readText(target, signal)
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
|
||||
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
||||
}
|
||||
|
||||
async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
|
||||
if (entry.isDirectory()) return 'directory'
|
||||
if (entry.isFile()) return 'file'
|
||||
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
|
||||
if (!entry.isSymbolicLink()) return undefined
|
||||
try {
|
||||
const info = await stat(fullPath)
|
||||
if (info.isDirectory()) return 'directory'
|
||||
if (info.isFile()) return 'file'
|
||||
return undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||
const firstLineEnd = raw.indexOf('\n')
|
||||
if (firstLineEnd < 0) return undefined
|
||||
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
|
||||
if (firstLine !== '---') return undefined
|
||||
const start = firstLineEnd + 1
|
||||
const closing = findClosingFrontmatter(raw, start)
|
||||
if (closing === undefined) return undefined
|
||||
const yaml = raw.slice(start, closing.start)
|
||||
const parsed = parseYaml(yaml) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
||||
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
|
||||
}
|
||||
|
||||
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
|
||||
let lineStart = start
|
||||
while (lineStart <= raw.length) {
|
||||
const nextNewline = raw.indexOf('\n', lineStart)
|
||||
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
||||
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
|
||||
if (line === '---') {
|
||||
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
|
||||
}
|
||||
if (nextNewline < 0) return undefined
|
||||
lineStart = nextNewline + 1
|
||||
}
|
||||
}
|
||||
|
||||
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
|
||||
let current = cwd
|
||||
while (true) {
|
||||
if (await pathExists(join(current, '.git'), fs)) {
|
||||
return current
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return cwd
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
|
||||
if (fs !== undefined) {
|
||||
return await pathExistsInFileSystem(path, fs)
|
||||
}
|
||||
return await pathExistsInNode(path)
|
||||
}
|
||||
|
||||
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
|
||||
let target
|
||||
try {
|
||||
target = await fs.resolve(path)
|
||||
} catch {
|
||||
// A backend may reject or hide this candidate; continue walking upward.
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return await fs.stat(target) !== undefined
|
||||
} catch {
|
||||
// Transient stat failures make only this git-root candidate unusable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExistsInNode(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
// Missing host paths are expected while walking toward the filesystem root.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
|
||||
const value = data[key]
|
||||
return typeof value === 'boolean' ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
|
||||
const value = data.metadata
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return { metadata: value as Record<string, unknown> }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import * as SkillLocal from '../src/index.ts'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
class TestFileSystem extends FileSystem {
|
||||
listDirCalls = 0
|
||||
failResolvePaths = new Set<string>()
|
||||
failStatPaths = new Set<string>()
|
||||
statOverrides = new Map<string, FsInfo | undefined>()
|
||||
statSignals: Array<AbortSignal | undefined> = []
|
||||
readTextSignals: Array<AbortSignal | undefined> = []
|
||||
readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
|
||||
return { targetKey: path as never, displayPath: path }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
this.statSignals.push(signal)
|
||||
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
|
||||
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const info = await fs.stat(target.displayPath)
|
||||
return {
|
||||
version: FsVersion(String(info.mtimeMs)),
|
||||
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
|
||||
size: info.size,
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||||
this.readTextSignals.push(signal)
|
||||
if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
|
||||
const text = await readFile(target.displayPath, 'utf8')
|
||||
if (text.includes('\uFFFD')) throw new Error('not text')
|
||||
return text
|
||||
}
|
||||
|
||||
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
throw new Error('not needed in skill tests')
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
this.listDirCalls += 1
|
||||
const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
|
||||
const result: FsDirEntry[] = []
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const childPath = join(target.displayPath, entry.name)
|
||||
let type: FsInfo['type'] = 'other'
|
||||
let size: number | undefined
|
||||
try {
|
||||
const info = await stat(childPath)
|
||||
type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
size = info.isFile() ? info.size : undefined
|
||||
} catch {
|
||||
type = 'other'
|
||||
}
|
||||
result.push({
|
||||
name: entry.name,
|
||||
type,
|
||||
target: { targetKey: childPath as never, displayPath: childPath },
|
||||
version: FsVersion('test'),
|
||||
...(size !== undefined ? { size } : {}),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
|
||||
await mkdir(dirname(target.displayPath), { recursive: true })
|
||||
await writeFile(target.displayPath, content)
|
||||
return { operation: 'create', version: FsVersion('test'), before: null, after: content }
|
||||
}
|
||||
|
||||
override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
|
||||
throw new Error('not needed in skill tests')
|
||||
}
|
||||
}
|
||||
|
||||
async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
...config,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('dsh-skill-local plugin exports', () => {
|
||||
it('declares stable plugin metadata', () => {
|
||||
expect(SkillLocal.name).toBe('skill-local')
|
||||
expect(SkillLocal.inject).toEqual(['skills'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalSkillProvider', () => {
|
||||
it('discovers project, custom, user, and agents skill roots in priority order', async () => {
|
||||
const home = await tempDir('skill-home')
|
||||
const project = await tempDir('skill-project')
|
||||
const custom = await tempDir('skill-custom')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
|
||||
await writeSkill(custom, 'same', 'custom skill')
|
||||
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
|
||||
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
|
||||
await writeSkill(custom, 'custom-only', 'custom only')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
|
||||
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
|
||||
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
|
||||
['custom-only', 'custom only'],
|
||||
['same', 'project dsh skill'],
|
||||
])
|
||||
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
|
||||
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
|
||||
|
||||
const noGit = await tempDir('skill-no-git')
|
||||
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
|
||||
expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
|
||||
})
|
||||
|
||||
it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
|
||||
const home = await tempDir('skill-runtime-priority')
|
||||
const project = await tempDir('skill-runtime-project')
|
||||
const custom = await tempDir('skill-runtime-custom')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
|
||||
await writeSkill(custom, 'runtime-name', 'Custom loses')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
ctx.skills.register({
|
||||
name: 'project-name',
|
||||
description: 'Runtime loses to project',
|
||||
content: 'Runtime body.',
|
||||
source: 'runtime',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'runtime-name',
|
||||
description: 'Runtime wins',
|
||||
content: 'Runtime body.',
|
||||
source: 'runtime',
|
||||
})
|
||||
|
||||
expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
|
||||
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
|
||||
})
|
||||
|
||||
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
|
||||
const home = await tempDir('skill-flat')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
|
||||
await writeFile(join(root, 'rich-skill.md'), [
|
||||
'---',
|
||||
'name: rich-skill',
|
||||
'description: rich description',
|
||||
'whenToUse: For richer local parsing',
|
||||
'disableModelInvocation: false',
|
||||
'metadata:',
|
||||
' owner: tests',
|
||||
'---',
|
||||
'',
|
||||
'Rich body.',
|
||||
].join('\n'))
|
||||
await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
|
||||
await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
|
||||
await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
|
||||
await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
|
||||
await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
|
||||
await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
|
||||
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
|
||||
await writeFile(join(root, 'notes.txt'), 'ignored')
|
||||
await mkdir(join(root, 'not-a-skill'), { recursive: true })
|
||||
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
|
||||
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
const listedBeforeDelete = await ctx.skills.list()
|
||||
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
|
||||
if (flatSummary === undefined) throw new Error('expected flat-skill')
|
||||
await writeFile(join(root, 'flat-skill.md'), '')
|
||||
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
|
||||
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
|
||||
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
|
||||
expect(await ctx.skills.get('rich-skill')).toMatchObject({
|
||||
whenToUse: 'For richer local parsing',
|
||||
disableModelInvocation: false,
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
|
||||
const home = await tempDir('skill-frontmatter-crlf')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'crlf-skill.md'), [
|
||||
'---',
|
||||
'name: crlf-skill',
|
||||
'description: CRLF skill',
|
||||
'metadata:',
|
||||
' marker: "----"',
|
||||
'---',
|
||||
'',
|
||||
'CRLF body.',
|
||||
].join('\r\n'))
|
||||
await writeFile(join(root, 'block-skill.md'), [
|
||||
'---',
|
||||
'name: block-skill',
|
||||
'description: |',
|
||||
' Includes a ---- marker that is not a delimiter.',
|
||||
'---',
|
||||
'',
|
||||
'Block body.',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
|
||||
expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
|
||||
expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
|
||||
expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
|
||||
})
|
||||
|
||||
it('skips invalid YAML skill files without hiding valid siblings', async () => {
|
||||
const home = await tempDir('skill-invalid-yaml')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'good-skill', 'Good skill')
|
||||
await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
})
|
||||
|
||||
it('discovers symlinked skill directories and flat files', async () => {
|
||||
const home = await tempDir('skill-symlink-home')
|
||||
const external = await tempDir('skill-symlink-external')
|
||||
await writeSkill(external, 'linked-dir', 'Linked directory')
|
||||
await writeFlatSkill(external, 'linked-flat', 'Linked flat')
|
||||
await mkdir(join(home, '.dsh/skills'), { recursive: true })
|
||||
await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
|
||||
await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
|
||||
await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
|
||||
await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
|
||||
})
|
||||
|
||||
it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
|
||||
const home = await tempDir('skill-read-fs')
|
||||
const project = await tempDir('skill-project-root-backend')
|
||||
const nestedCwd = join(project, 'packages/app')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(nestedCwd, { recursive: true })
|
||||
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
|
||||
await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
|
||||
await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
|
||||
await mkdir(join(root, 'empty-dir'), { recursive: true })
|
||||
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
|
||||
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
|
||||
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from('\n'),
|
||||
]))
|
||||
await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TestFileSystem)
|
||||
const fs = ctx.fs as TestFileSystem
|
||||
fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
|
||||
fs.failStatPaths.add(join(root, 'stat-fail.md'))
|
||||
fs.failResolvePaths.add(join(nestedCwd, '.git'))
|
||||
fs.failStatPaths.add(join(project, 'packages/.git'))
|
||||
fs.statOverrides.set(join(project, '.git'), {
|
||||
version: FsVersion('virtual-git'),
|
||||
type: 'directory',
|
||||
size: 0,
|
||||
})
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
|
||||
['backend-root', 'project-agents'],
|
||||
['text-skill', 'user-dsh'],
|
||||
])
|
||||
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards cancellation to filesystem reads while loading a skill', async () => {
|
||||
const home = await tempDir('skill-read-abort')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TestFileSystem)
|
||||
const fs = ctx.fs as TestFileSystem
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
|
||||
|
||||
fs.statSignals = []
|
||||
fs.readTextSignals = []
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
fs.readTextOverride = async (_target, signal) => {
|
||||
if (signal === undefined) throw new Error('expected the skill lookup signal')
|
||||
started.resolve(undefined)
|
||||
return await new Promise<string>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const abortReason = signal.reason as unknown
|
||||
reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason)))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('turn cancelled')
|
||||
const loading = ctx.skills.get('abortable-skill', { signal: controller.signal })
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(loading).rejects.toBe(reason)
|
||||
expect(fs.statSignals).toEqual([controller.signal])
|
||||
expect(fs.readTextSignals).toEqual([controller.signal])
|
||||
})
|
||||
|
||||
it('uses default home root resolution without exposing builtin skills', async () => {
|
||||
const previousDshHome = process.env.DSH_HOME
|
||||
const previousAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const envHome = await tempDir('skill-env-home')
|
||||
try {
|
||||
process.env.DSH_HOME = join(envHome, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
|
||||
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
|
||||
const empty = new Context()
|
||||
await empty.plugin(SkillService)
|
||||
SkillLocal.apply(empty, {})
|
||||
expect(await empty.skills.list()).toEqual([])
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = previousDshHome
|
||||
}
|
||||
if (previousAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = previousAgentsHome
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../fs/fs" },
|
||||
{ "path": "../skill" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-skill
|
||||
|
||||
Pure agent skill provider registry.
|
||||
|
||||
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
|
||||
|
||||
## Service: `SkillService` (ctx key: `skills`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalog snapshots kept in memory. |
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token.
|
||||
|
||||
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill",
|
||||
"description": "Agent skill provider registry for the DeepSeek Harness",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Agent skill provider registry.
|
||||
*
|
||||
* This package is the interface third of the skill capability seam. Concrete
|
||||
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
|
||||
* from; this service only merges provider catalogs, resolves the winning skill
|
||||
* for a name, and exposes the winning summaries and definitions to consumers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||
const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
|
||||
/**
|
||||
* Return whether a string is a valid kebab-case skill name.
|
||||
* @param name - candidate skill name to validate.
|
||||
* @returns whether the name matches the public skill-name grammar.
|
||||
*/
|
||||
export function isSkillName(name: string): boolean {
|
||||
return SKILL_NAME.test(name)
|
||||
}
|
||||
|
||||
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
|
||||
|
||||
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
|
||||
export type SkillResourceBase =
|
||||
| { kind: 'directory'; path: string }
|
||||
| { kind: 'url'; url: string }
|
||||
| { kind: 'opaque'; description: string }
|
||||
|
||||
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
|
||||
export interface SkillSummary {
|
||||
/** Kebab-case identifier used with the `skill` tool. */
|
||||
name: string
|
||||
/** Short routing description shown to the model. */
|
||||
description: string
|
||||
/** Optional extra routing guidance shown to the model. */
|
||||
whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
disableModelInvocation?: boolean
|
||||
/** Discovery source that produced this winning skill. */
|
||||
source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
provider: string
|
||||
/** Provider-specific base for relative resources. */
|
||||
resourceBase?: SkillResourceBase
|
||||
}
|
||||
|
||||
/** Provider catalog entry used by the registry to merge and later load skills. */
|
||||
export interface SkillCandidate extends SkillSummary {
|
||||
/** Lower ranks win duplicate skill names before provider registration order is considered. */
|
||||
rank: number
|
||||
/** Opaque provider-owned handle passed back to `provider.get()`. */
|
||||
locator: unknown
|
||||
/** Absolute file path when the provider has one. */
|
||||
path?: string
|
||||
/** Parsed optional metadata object from provider-specific skill frontmatter. */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
|
||||
export interface SkillDefinition extends SkillSummary {
|
||||
/** Markdown instruction body after any provider-specific metadata removal. */
|
||||
content: string
|
||||
/** Absolute file path when the skill came from disk. */
|
||||
path?: string
|
||||
/** Parsed optional metadata object from frontmatter. */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
|
||||
|
||||
/** Caller context used for cwd-sensitive and abortable provider work. */
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
/** Abort discovery or loading work for the current caller. */
|
||||
signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
export interface SkillProvider {
|
||||
/** Unique provider name in the `ctx.skills` registry. */
|
||||
name: string
|
||||
/**
|
||||
* List available skill candidates for the current lookup context. Provider
|
||||
* plugins register synchronously during `apply()`; remote initialization,
|
||||
* authentication, and discovery are awaited inside this method. Implementations
|
||||
* should settle promptly when `options.signal` aborts.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns provider candidates with precedence ranks and opaque locators.
|
||||
*/
|
||||
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
|
||||
/**
|
||||
* Load a complete skill body for a previously listed candidate.
|
||||
* @param candidate - the winning candidate originally returned by this provider.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill body, or `undefined` if it is no longer loadable.
|
||||
*/
|
||||
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
|
||||
}
|
||||
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum number of completed cwd/provider catalog snapshots kept in memory. */
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
skills: SkillService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A skill provider became resolvable in the `ctx.skills` registry.
|
||||
* Consumers can observe this instead of depending on Cordis plugin load
|
||||
* order, which is concurrent for sibling plugins.
|
||||
* @param provider - the provider that just registered.
|
||||
* @mode emit
|
||||
*/
|
||||
'skill/provider-added'(provider: SkillProvider): void
|
||||
/**
|
||||
* A skill provider left the registry because its plugin fiber was disposed.
|
||||
* @param name - the registry name that no longer resolves.
|
||||
* @mode emit
|
||||
*/
|
||||
'skill/provider-removed'(name: string): void
|
||||
}
|
||||
}
|
||||
|
||||
interface IndexedCandidate {
|
||||
candidate: SkillCandidate
|
||||
provider: SkillProvider
|
||||
providerOrder: number
|
||||
localOrder: number
|
||||
}
|
||||
|
||||
interface CollectResult {
|
||||
entries: IndexedCandidate[]
|
||||
cacheable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of skill providers. It merges provider catalogs with stable
|
||||
* first-wins duplicate handling, exposes sorted model-visible summaries, and
|
||||
* loads full skill bodies on demand.
|
||||
*/
|
||||
export class SkillService extends Service {
|
||||
static Config: Schema<Config> = z.object({
|
||||
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
|
||||
})
|
||||
|
||||
private readonly collectCacheMaxEntries: number
|
||||
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
|
||||
private readonly runtime = new Map<string, SkillDefinition>()
|
||||
private readonly collectCache = new Map<string, IndexedCandidate[]>()
|
||||
private providerRevision = 0
|
||||
private nextProviderOrder = 0
|
||||
private runtimeRevision = 0
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'skills')
|
||||
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
|
||||
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a skill provider synchronously during the provider plugin's
|
||||
* `apply()`. Throws if another provider already owns the same provider name,
|
||||
* including the reserved runtime provider name. Providers that need remote
|
||||
* initialization do that work inside `list()` after registration. Effect-
|
||||
* scoped and HMR-safe: disposing the caller's fiber unregisters the provider
|
||||
* and invalidates cached catalogs.
|
||||
* @param provider - the provider to register by `provider.name`.
|
||||
* @returns a disposer that unregisters this provider.
|
||||
*/
|
||||
registerProvider(provider: SkillProvider): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: SkillService) {
|
||||
if (provider.name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
}
|
||||
if (this.providers.has(provider.name)) {
|
||||
throw new Error(`a skill provider named "${provider.name}" is already registered`)
|
||||
}
|
||||
this.providers.set(provider.name, { provider, order: this.nextProviderOrder })
|
||||
this.nextProviderOrder += 1
|
||||
this.invalidateCache()
|
||||
yield () => {
|
||||
this.providers.delete(provider.name)
|
||||
this.invalidateCache()
|
||||
this.ctx.emit('skill/provider-removed', provider.name)
|
||||
}
|
||||
this.ctx.emit('skill/provider-added', provider)
|
||||
}.bind(this), 'skills.registerProvider()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a runtime skill contribution. Runtime registrations are treated as
|
||||
* embedded provider entries with project-over-user priority. Same-name runtime
|
||||
* registrations are first-wins: a duplicate logs a warning and gets a no-op
|
||||
* disposer so it cannot remove the active contribution.
|
||||
* @param skill - the complete skill definition to expose for discovery.
|
||||
* @returns a disposer that removes this runtime contribution and invalidates caches.
|
||||
*/
|
||||
register(skill: SkillRegistration): () => void {
|
||||
const normalized = normalizeRuntimeSkill(skill)
|
||||
const existing = this.runtime.get(normalized.name)
|
||||
if (existing !== undefined) {
|
||||
this.ctx.logger.warn(`runtime skill "${normalized.name}" ignored because it is already registered`)
|
||||
return () => {}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SkillService) {
|
||||
this.runtime.set(normalized.name, normalized)
|
||||
this.runtimeRevision += 1
|
||||
this.invalidateCache()
|
||||
yield () => {
|
||||
this.runtime.delete(normalized.name)
|
||||
this.runtimeRevision += 1
|
||||
this.invalidateCache()
|
||||
}
|
||||
}.bind(this), 'skills.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* @param name - kebab-case skill name.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill, including body content, or `undefined`.
|
||||
*/
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
if (!isSkillName(name)) return undefined
|
||||
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
|
||||
if (match === undefined) return undefined
|
||||
return await match.provider.get(match.candidate, options)
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
options.signal?.throwIfAborted()
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
const key = collectCacheKey(options, providerRevision, runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
options.signal?.throwIfAborted()
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
||||
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
|
||||
this.collectCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return result.entries
|
||||
}
|
||||
}
|
||||
|
||||
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
const collected = await this.listAllCandidates(options)
|
||||
collected.entries.sort(compareIndexedCandidates)
|
||||
const seen = new Set<string>()
|
||||
const result: IndexedCandidate[] = []
|
||||
for (const entry of collected.entries) {
|
||||
const skill = entry.candidate
|
||||
if (seen.has(skill.name)) {
|
||||
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
|
||||
continue
|
||||
}
|
||||
seen.add(skill.name)
|
||||
result.push(entry)
|
||||
}
|
||||
return { entries: result, cacheable: collected.cacheable }
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
options.signal?.throwIfAborted()
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
||||
candidates.push({
|
||||
candidate: runtimeCandidate(skill),
|
||||
provider: RUNTIME_SKILL_PROVIDER,
|
||||
providerOrder: -1,
|
||||
localOrder: runtimeOrder,
|
||||
})
|
||||
runtimeOrder += 1
|
||||
}
|
||||
for (const { provider, order } of [...this.providers.values()]) {
|
||||
let localOrder = 0
|
||||
let listed: SkillCandidate[] | undefined
|
||||
try {
|
||||
listed = await waitWithAbort(provider.list(options), options.signal)
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted === true) throw toError(options.signal.reason)
|
||||
cacheable = false
|
||||
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
|
||||
}
|
||||
if (listed === undefined) continue
|
||||
for (const candidate of listed) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
localOrder += 1
|
||||
}
|
||||
}
|
||||
return { entries: candidates, cacheable }
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.providerRevision += 1
|
||||
this.collectCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
|
||||
name: RUNTIME_PROVIDER,
|
||||
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
|
||||
list() {
|
||||
return Promise.resolve([])
|
||||
},
|
||||
get(candidate) {
|
||||
const skill = candidate.locator as SkillDefinition
|
||||
return Promise.resolve({ ...skill })
|
||||
},
|
||||
}
|
||||
|
||||
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
|
||||
return {
|
||||
...toSummary(skill),
|
||||
rank: RUNTIME_RANK,
|
||||
locator: skill,
|
||||
...skill.path !== undefined ? { path: skill.path } : {},
|
||||
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
|
||||
if (!SKILL_NAME.test(candidate.name)) {
|
||||
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
|
||||
}
|
||||
if (candidate.description.length === 0) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
|
||||
}
|
||||
if (!Number.isFinite(candidate.rank)) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
|
||||
}
|
||||
if (candidate.provider !== providerName) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
|
||||
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
|
||||
return {
|
||||
...skill,
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
source: skill.source,
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return compareCodePoints(left.name, right.name)
|
||||
}
|
||||
|
||||
function compareCodePoints(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
|
||||
return left.candidate.rank - right.candidate.rank
|
||||
|| left.providerOrder - right.providerOrder
|
||||
|| left.localOrder - right.localOrder
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
signal.throwIfAborted()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(toError(signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(toError(error))
|
||||
},
|
||||
)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export default SkillService
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
provider: 'memory',
|
||||
source: 'memory',
|
||||
rank,
|
||||
locator: { content: body },
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryProvider implements SkillProvider {
|
||||
readonly name = 'memory'
|
||||
listCalls = 0
|
||||
|
||||
constructor(private candidates: SkillCandidate[]) {}
|
||||
|
||||
async list(_options: SkillLookupOptions): Promise<SkillCandidate[]> {
|
||||
this.listCalls += 1
|
||||
return this.candidates
|
||||
}
|
||||
|
||||
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as { content: string }
|
||||
return { ...candidate, content: locator.content }
|
||||
}
|
||||
|
||||
replace(candidates: SkillCandidate[]): void {
|
||||
this.candidates = candidates
|
||||
}
|
||||
}
|
||||
|
||||
describe('SkillService registry', () => {
|
||||
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 20),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
memorySkill('shadowed', 'Lower priority', 20),
|
||||
])
|
||||
const overrideProvider: SkillProvider = {
|
||||
name: 'override',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'shadowed',
|
||||
description: 'Higher priority',
|
||||
provider: 'override',
|
||||
source: 'override',
|
||||
rank: 5,
|
||||
locator: { content: 'Override body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
const disposeMemory = ctx.skills.registerProvider(provider)
|
||||
ctx.skills.registerProvider(overrideProvider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
|
||||
['a-skill', 'A skill', 'memory'],
|
||||
['shadowed', 'Higher priority', 'override'],
|
||||
['z-skill', 'Z skill', 'memory'],
|
||||
])
|
||||
expect((await ctx.skills.get('shadowed'))?.content).toBe('Override body.')
|
||||
const sameRankProvider: SkillProvider = {
|
||||
name: 'same-rank',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'same-rank-skill',
|
||||
description: 'Same rank',
|
||||
provider: 'same-rank',
|
||||
source: 'same-rank',
|
||||
rank: 10,
|
||||
locator: { content: 'Same rank body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(sameRankProvider)
|
||||
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
|
||||
await expect(ctx.plugin({
|
||||
name: 'duplicate-memory',
|
||||
inject: ['skills'],
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.skills.registerProvider(new MemoryProvider([]))
|
||||
},
|
||||
})).rejects.toThrow('already registered')
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})).toThrow('reserved')
|
||||
|
||||
disposeMemory()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
})
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const defaultedService = new SkillService(new Context())
|
||||
expect(await defaultedService.list()).toEqual([])
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
name: 'bad',
|
||||
async list() {
|
||||
return [memorySkill('Bad_Name', 'bad', 1)]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await expect(ctx.skills.list()).rejects.toThrow('invalid skill name')
|
||||
|
||||
const invalidCandidates = [
|
||||
{ ...memorySkill('empty-description', '', 1), provider: 'empty-description' },
|
||||
{ ...memorySkill('bad-rank', 'Bad rank', Number.NaN), provider: 'bad-rank' },
|
||||
{ ...memorySkill('wrong-provider', 'Wrong provider', 1), provider: 'different' },
|
||||
]
|
||||
for (const candidate of invalidCandidates) {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SkillService)
|
||||
invalid.skills.registerProvider({
|
||||
name: candidate.name,
|
||||
async list() {
|
||||
return [candidate]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await expect(invalid.skills.list()).rejects.toThrow('skill provider')
|
||||
}
|
||||
|
||||
await expect(new Context().plugin(SkillService, { collectCacheMaxEntries: 1.5 })).rejects.toThrow('collectCacheMaxEntries')
|
||||
})
|
||||
|
||||
it('sorts model-visible summaries without locale-sensitive collation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 10),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
]))
|
||||
const localeCompare = vi.spyOn(String.prototype, 'localeCompare')
|
||||
const sort = vi.spyOn(Array.prototype, 'sort')
|
||||
|
||||
try {
|
||||
const skills = await ctx.skills.list()
|
||||
expect(skills.map(skill => skill.name)).toEqual(['a-skill', 'z-skill'])
|
||||
expect(localeCompare).not.toHaveBeenCalled()
|
||||
|
||||
const summaryComparator = sort.mock.calls.at(-1)?.[0]
|
||||
expect(summaryComparator).toBeTypeOf('function')
|
||||
expect(summaryComparator?.(skills[0], skills[0])).toBe(0)
|
||||
} finally {
|
||||
sort.mockRestore()
|
||||
localeCompare.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
|
||||
const disposeRuntime = ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
source: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
path: 'memory://runtime-skill',
|
||||
metadata: { owner: 'tests' },
|
||||
content: 'Runtime body.',
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill', 'second-skill'])
|
||||
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
|
||||
content: 'Runtime body.',
|
||||
path: 'memory://runtime-skill',
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
disposeRuntime()
|
||||
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
|
||||
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
|
||||
|
||||
let fail = true
|
||||
let flakyCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
name: 'flaky',
|
||||
async list() {
|
||||
flakyCalls += 1
|
||||
if (fail) throw new Error('transient discovery failure')
|
||||
return [{ ...memorySkill('flaky-skill', 'Flaky', 10), provider: 'flaky' }]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(flakyCalls).toBe(1)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(flakyCalls).toBe(2)
|
||||
fail = false
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
|
||||
expect(flakyCalls).toBe(3)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('abandons an in-flight catalog when provider registrations change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const dispose = ctx.skills.registerProvider({
|
||||
name: 'delayed',
|
||||
async list() {
|
||||
markStarted?.()
|
||||
await gate
|
||||
return [{ ...memorySkill('stale-skill', 'Stale', 10), provider: 'delayed' }]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: 'Stale body.' }
|
||||
},
|
||||
})
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started
|
||||
dispose()
|
||||
release?.()
|
||||
|
||||
expect(await pending).toEqual([])
|
||||
})
|
||||
|
||||
it('stops waiting for discovery when its lookup signal aborts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
let seenSignal: AbortSignal | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const held = new Promise<SkillCandidate[]>((resolve) => {
|
||||
release = () => { resolve([]) }
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
name: 'uncooperative',
|
||||
list(options) {
|
||||
seenSignal = options.signal
|
||||
markStarted?.()
|
||||
return held
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const reason = 'discovery cancelled'
|
||||
const pending = ctx.skills.list({ signal: controller.signal })
|
||||
const outcome = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error instanceof Error && error.message === reason ? 'aborted' : 'other-error',
|
||||
)
|
||||
await started
|
||||
controller.abort(reason)
|
||||
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
|
||||
])
|
||||
release?.()
|
||||
await pending.catch(() => undefined)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
expect(settled).toBe('aborted')
|
||||
})
|
||||
|
||||
it('does not miss an abort racing listener installation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const reason = new Error('racing abort')
|
||||
let aborted = false
|
||||
const signal = {
|
||||
get aborted() {
|
||||
return aborted
|
||||
},
|
||||
reason,
|
||||
throwIfAborted() {
|
||||
if (aborted) throw reason
|
||||
},
|
||||
addEventListener(_type: string, listener: () => void) {
|
||||
aborted = true
|
||||
listener()
|
||||
},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
ctx.skills.registerProvider({
|
||||
name: 'racing-abort',
|
||||
list() {
|
||||
return Promise.reject(new Error('late provider failure'))
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list({ signal })).rejects.toBe(reason)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
|
||||
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
|
||||
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
|
||||
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
|
||||
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
|
||||
disposeSecond()
|
||||
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
|
||||
disposeFirst()
|
||||
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-tool-skill
|
||||
|
||||
The model-facing skill catalog and `skill` tool.
|
||||
|
||||
Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
|
||||
|
||||
## Session-prefix catalog
|
||||
|
||||
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available.
|
||||
|
||||
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix RFC](../../../docs/rfc/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message.
|
||||
|
||||
## Tool: `skill`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns one text tool result with `<skill_content name="...">`, containing `<skill_resources>` followed by `<skill_instructions>`. Resource guidance resolves paths or URLs explicitly referenced by the loaded instructions against `resourceBase`; referenced scripts, references, and assets load only when needed, and the tool does not enumerate a skill directory. Local filesystem skills provide a base directory, while remote or embedded providers can provide a URL or opaque provider-managed guidance. A name that cannot be resolved reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation: true` retain distinct `isError` results.
|
||||
|
||||
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-skill",
|
||||
"description": "Model-facing skill loading tool for the DeepSeek Harness",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.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-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Session-prefix skill catalog and model-facing `skill` loader tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-skill
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
/** Maximum normalized description length rendered in the session catalog; minimum 3. */
|
||||
catalogDescriptionMaxLength?: number
|
||||
}
|
||||
|
||||
/** Validate and default the model-facing skill catalog configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
|
||||
})
|
||||
|
||||
/** Register the session-prefix skill catalog and the model-facing skill loader. */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
|
||||
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
|
||||
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
|
||||
const rest = await next()
|
||||
if (skills.length === 0) return rest
|
||||
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
|
||||
})
|
||||
|
||||
const skillTool = defineTool({
|
||||
name: 'skill',
|
||||
description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
|
||||
if (!skill) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return [{ type: 'text', text: renderSkillContent(skill) }]
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
|
||||
},
|
||||
})
|
||||
ctx.tools.register(skillTool)
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${escapeAttr(skill.name)}">`,
|
||||
'<skill_resources>',
|
||||
...resourceHint,
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
skill.content,
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
const base = skill.resourceBase
|
||||
if (base === undefined) {
|
||||
return [
|
||||
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
}
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return [
|
||||
`Base directory for this skill: ${escapeText(base.path)}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'url':
|
||||
return [
|
||||
`Base URL for this skill: ${escapeText(base.url)}`,
|
||||
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'opaque':
|
||||
return [
|
||||
`Resources for this skill: ${escapeText(base.description)}`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): Message {
|
||||
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.plugin(toolSkill, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', agentForCwd(cwd), empty, signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
describe('dsh-tool-skill', () => {
|
||||
it('registers the skill tool schema and removes it on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const home = await tempDir('tool-schema')
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
|
||||
|
||||
const fiber = await ctx.plugin(toolSkill)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Load skill project-skill',
|
||||
kind: 'read',
|
||||
rawInput: 'project-skill',
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
|
||||
toolSkill.apply(ctx)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
})
|
||||
|
||||
it('forwards the session-prefix abort signal to skill discovery', async () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('contributes a stable name-and-description catalog through the session prefix', async () => {
|
||||
const home = await tempDir('tool-catalog')
|
||||
const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
|
||||
ctx.skills.register({
|
||||
name: 'z-skill',
|
||||
description: 'Long description '.repeat(5),
|
||||
whenToUse: 'Never render this routing hint.',
|
||||
source: 'secret-source',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'directory', path: '/secret/path' },
|
||||
content: 'Secret body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'a-skill',
|
||||
description: 'Use {{placeholder}} <safely> & carefully.',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'A body.',
|
||||
})
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
...await next(),
|
||||
])
|
||||
|
||||
const prefix = await composePrefix(ctx, '/workspace')
|
||||
|
||||
expect(prefix).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
'- `a-skill`: Use {{placeholder}} <safely> & carefully.',
|
||||
'- `z-skill`: Long description Long description Long descript...',
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
])
|
||||
const rendered = JSON.stringify(prefix[0])
|
||||
expect(rendered).not.toContain('whenToUse')
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
expect(rendered).not.toContain('Secret body')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('does not contribute a session-prefix message when no skills are available', async () => {
|
||||
const home = await tempDir('tool-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
})
|
||||
|
||||
it('validates the catalog description cap', async () => {
|
||||
const home = await tempDir('tool-invalid-catalog-cap')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
|
||||
})
|
||||
|
||||
it('loads a skill for the calling agent cwd', async () => {
|
||||
const home = await tempDir('tool-load')
|
||||
const project = await tempDir('tool-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'project-skill' },
|
||||
agent: { session: { header: { cwd: project } } } as never,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
expect(block?.type).toBe('text')
|
||||
if (block?.type !== 'text') throw new Error('expected text skill result')
|
||||
expect(block.text).toBe([
|
||||
'<skill_content name="project-skill">',
|
||||
'<skill_resources>',
|
||||
`Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
'Project instructions.',
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n'))
|
||||
expect(block.text).not.toContain('# Skill:')
|
||||
})
|
||||
|
||||
it('renders provider-managed resource hints for non-local skills', async () => {
|
||||
const home = await tempDir('tool-resource-hints')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'opaque-skill',
|
||||
description: 'Opaque skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
content: 'Opaque instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'url-skill',
|
||||
description: 'URL skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
|
||||
content: 'URL instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'provider-skill',
|
||||
description: 'Provider skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Provider instructions.',
|
||||
})
|
||||
|
||||
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
|
||||
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
|
||||
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
|
||||
|
||||
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
|
||||
throw new Error('expected text tool results')
|
||||
}
|
||||
expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
expect(url.content[0].text).toContain('<skill_resources>\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n</skill_resources>')
|
||||
expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
})
|
||||
|
||||
it('fails loud on an unknown resource base kind', async () => {
|
||||
const home = await tempDir('tool-resource-assert-never')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'rogue-resource-skill',
|
||||
description: 'Rogue resource skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'future' } as never,
|
||||
content: 'Rogue instructions.',
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('unreachable variant')
|
||||
})
|
||||
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
const unknownBlock = unknown.content[0]
|
||||
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../skill" },
|
||||
{ "path": "../../core/tools" }
|
||||
]
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export interface AcpRunSpec {
|
||||
* (the seam contract forbids `result` rejecting). The driver calls this with
|
||||
* the original error and the chosen stop reason so the fault is preserved
|
||||
* rather than silently lost; the provider wires it to `ctx.logger.warn`.
|
||||
* A throw from the sink itself is contained — it cannot reject `result`.
|
||||
* Optional — omitted in a unit test that asserts the stop reason directly.
|
||||
*/
|
||||
onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
@@ -336,7 +337,13 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su
|
||||
// (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a
|
||||
// local bug. Flatten to `error` and surface the original via onError so a
|
||||
// real fault is preserved rather than silently lost.
|
||||
spec.onError?.(toError(error), 'error')
|
||||
try {
|
||||
spec.onError?.(toError(error), 'error')
|
||||
} catch {
|
||||
// Swallows only the caller-supplied sink's OWN throw: an unguarded
|
||||
// sink exception would reject `result` and break the contract above.
|
||||
// The child-level failure being reported still settles as `error`.
|
||||
}
|
||||
return { output: collectOutput(), stopReason: 'error' }
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -483,6 +483,28 @@ describe('dsh-subagent-acp', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('resolves error (never rejects) even when the onError sink itself throws', async () => {
|
||||
// onError is a caller-supplied callback boundary: its own exception must be
|
||||
// contained, or it would reject `result` and break the seam's "result never
|
||||
// rejects" contract that the flattening above exists to uphold.
|
||||
const run = startAcpRun(
|
||||
{ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent },
|
||||
{
|
||||
command: '/nonexistent/acp-agent-binary',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {},
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
onError: () => { throw new Error('sink boom') },
|
||||
},
|
||||
)
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
|
||||
// The child hangs, we cancel, and instead of answering the child exits hard
|
||||
// — the pending prompt RPC rejects. With a cancel already requested, the
|
||||
|
||||
@@ -6,7 +6,7 @@ Three layers, importable separately:
|
||||
|
||||
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo).
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), and the composable `scrubRequestHeaders` (header bulk → `{{system}}`/`{{tools}}`, structure kept — [pinned-header RFC](../../../docs/rfc/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 golden + re-persisted-log compares, record-mode fixture write-back, the per-header-class pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, pinning fixtures well-formed, non-pinning fixtures header-scrubbed). Must be called at vitest collection time.
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, pinning fixtures well-formed, non-pinning fixtures header-scrubbed). Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
|
||||
@@ -27,12 +27,16 @@ defineAcpSnapshotSuite({
|
||||
},
|
||||
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
|
||||
scenarios: SCENARIOS, // exactly one entry per header class sets pinsHeader
|
||||
mode: process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay',
|
||||
mode: process.env.DSH_SNAPSHOT === 'record'
|
||||
? 'record'
|
||||
: process.env.DSH_SNAPSHOT === 'refresh'
|
||||
? 'refresh'
|
||||
: 'replay',
|
||||
})
|
||||
```
|
||||
|
||||
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 scenarios are the template.
|
||||
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout plus comparable session-log goldens from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
|
||||
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug).
|
||||
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript).
|
||||
@@ -85,6 +85,8 @@ export type InputStep =
|
||||
| { op: 'promptExpectError'; text: string }
|
||||
| { op: 'promptAndCancel'; text: string }
|
||||
| { op: 'cancel' }
|
||||
| { op: 'setConfigOption'; configId: string; value: string }
|
||||
| { op: 'setConfigOptionExpectError'; configId: string; value: string }
|
||||
|
||||
/** A scenario's `input.json`: an ordered list of input steps. */
|
||||
export interface InputScript {
|
||||
@@ -210,6 +212,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
DSH_SNAPSHOT: opts.mode,
|
||||
DSH_SNAPSHOT_FILE: opts.fixtureFile,
|
||||
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
|
||||
...opts.childFiles !== undefined && opts.childFiles.length > 0
|
||||
? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
|
||||
@@ -406,6 +410,24 @@ async function runStep(
|
||||
await client.cancel({ sessionId })
|
||||
return
|
||||
}
|
||||
case 'setConfigOption': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOption before newSession')
|
||||
await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value })
|
||||
return
|
||||
}
|
||||
case 'setConfigOptionExpectError': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOptionExpectError before newSession')
|
||||
// The bridge rejects an unknown id / out-of-vocabulary value; the SDK
|
||||
// surfaces that as a rejected RPC — swallow it so the run completes and
|
||||
// the error frame is captured in the transcript.
|
||||
await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value }).then(
|
||||
() => { throw new Error('snapshot-harness: expected set_config_option to be rejected but it succeeded') },
|
||||
() => { /* expected: the bridge rejected the id or value */ },
|
||||
)
|
||||
return
|
||||
}
|
||||
default:
|
||||
throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
*
|
||||
* `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the
|
||||
* `session.jsonl` fixtures against the real API and refreshes the stdout golden
|
||||
* in one pass; the caller resolves that env into {@link SnapshotSuiteOptions}
|
||||
* (env reading stays at the suite edge, not in this library).
|
||||
* in one pass. `pnpm run test:snapshot:refresh` (DSH_SNAPSHOT=refresh) instead
|
||||
* replays the committed model scripts keylessly and writes the current stdout
|
||||
* + persisted-log goldens back without calling a live LLM. The caller resolves
|
||||
* that env into {@link SnapshotSuiteOptions} (env reading stays at the suite
|
||||
* edge, not in this library).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp-snapshot/suite
|
||||
*/
|
||||
@@ -95,6 +98,15 @@ export interface Scenario {
|
||||
* Defaults to false.
|
||||
*/
|
||||
pinsHeader?: boolean
|
||||
/**
|
||||
* How many `request/header-delta` events this PINNING scenario's fixture
|
||||
* legitimately carries (default 0). A recorded mid-run header change — a
|
||||
* config-option switch rewriting a prompt section — is part of the pinned
|
||||
* surface, committed verbatim like the header itself; any OTHER count
|
||||
* still fails, so fixture rot stays caught. Meaningless off the pin (the
|
||||
* live uniformity guard keeps non-pinning scenarios delta-free).
|
||||
*/
|
||||
expectedHeaderDeltas?: number
|
||||
/**
|
||||
* Which header-composition class this scenario belongs to. Scenarios that
|
||||
* boot the same config compose the same header; each class has exactly one
|
||||
@@ -124,12 +136,13 @@ export interface SnapshotSuiteOptions {
|
||||
/** The scenario table; exactly one entry must set `pinsHeader`. */
|
||||
scenarios: Scenario[]
|
||||
/**
|
||||
* `replay` (keyless, the default tier) or `record` (live API; re-records the
|
||||
* `recorded` scenarios' fixtures and refreshes the vitest goldens under
|
||||
* `--update`). The caller derives this from `$DSH_SNAPSHOT` — env reading
|
||||
* stays outside this library.
|
||||
* `replay` (keyless, the default tier), `record` (live API; re-records the
|
||||
* `recorded` scenarios' fixtures and refreshes the Vitest goldens under
|
||||
* `--update`), or `refresh` (keyless replay that rewrites stdout goldens and
|
||||
* comparable session fixtures from the replay run). The caller derives this
|
||||
* from `$DSH_SNAPSHOT` — env reading stays outside this library.
|
||||
*/
|
||||
mode: 'replay' | 'record'
|
||||
mode: 'replay' | 'record' | 'refresh'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,6 +214,86 @@ export function headerDeltaCount(rawLog: string): number {
|
||||
.length
|
||||
}
|
||||
|
||||
/** A literal string replacement used to carry an existing fixture's volatile value into a refreshed log. */
|
||||
export interface FixtureReplacement {
|
||||
/** The fresh replay-run value to replace. */
|
||||
from: string
|
||||
/** The existing fixture value to keep. */
|
||||
to: string
|
||||
}
|
||||
|
||||
function parseJsonlRecords(text: string): Record<string, unknown>[] {
|
||||
return text.split('\n')
|
||||
.filter(line => line.trim().length > 0)
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-log id/cwd replacements used by refresh write-back.
|
||||
*
|
||||
* @param logs The freshly harvested logs, in fixture order.
|
||||
* @param fixtures The existing fixture contents, in matching order.
|
||||
* @returns Literal replacements from fresh volatile values to the fixture's old values.
|
||||
*/
|
||||
export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] {
|
||||
const replacements: FixtureReplacement[] = []
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0]
|
||||
const existing = parseJsonlRecords(fixtures[i] ?? '')[0]
|
||||
for (const field of ['id', 'cwd'] as const) {
|
||||
const from = fresh?.[field]
|
||||
const to = existing?.[field]
|
||||
if (typeof from === 'string' && typeof to === 'string' && from.length > 0 && from !== to) {
|
||||
replacements.push({ from, to })
|
||||
}
|
||||
}
|
||||
}
|
||||
return replacements
|
||||
}
|
||||
|
||||
function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Record<string, unknown> | undefined): void {
|
||||
if (existing === undefined || existing.type !== record.type) return
|
||||
if (record.type === 'session') {
|
||||
for (const field of ['id', 'createdAt', 'cwd', 'parentSession'] as const) {
|
||||
if (field in record && field in existing) record[field] = existing[field]
|
||||
}
|
||||
return
|
||||
}
|
||||
if ('time' in record && 'time' in existing) record.time = existing.time
|
||||
if (record.type !== 'hook/result') return
|
||||
const data = record.data
|
||||
const existingData = existing.data
|
||||
if (
|
||||
data !== null && typeof data === 'object'
|
||||
&& existingData !== null && typeof existingData === 'object'
|
||||
&& 'durationMs' in data && 'durationMs' in existingData
|
||||
) {
|
||||
(data as Record<string, unknown>).durationMs = (existingData as Record<string, unknown>).durationMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a fresh replay-produced log so repeated refreshes do not churn
|
||||
* volatile fixture fields. Meaningful event payloads come from `fresh`; the
|
||||
* existing fixture lends session ids, cwd, creation times, event times, and
|
||||
* hook durations where the record shape still matches.
|
||||
*
|
||||
* @param fresh The newly harvested session JSONL.
|
||||
* @param existing The committed fixture JSONL being refreshed.
|
||||
* @param replacements Cross-log literal replacements from {@link refreshFixtureReplacements}.
|
||||
* @returns The stabilized JSONL content to write back.
|
||||
*/
|
||||
export function stabilizeRefreshLog(fresh: string, existing: string, replacements: FixtureReplacement[]): string {
|
||||
let stable = fresh
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
const existingRecords = parseJsonlRecords(existing)
|
||||
const records = parseJsonlRecords(stable)
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
preserveFixtureVolatiles(records[i] as Record<string, unknown>, existingRecords[i])
|
||||
}
|
||||
return records.map(record => JSON.stringify(record)).join('\n') + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the suite: one `describe` per scenario (the golden/log compares and
|
||||
* the header-uniformity guard) plus the fixture guard block (no orphan
|
||||
@@ -215,6 +308,8 @@ export function headerDeltaCount(rawLog: string): number {
|
||||
export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
const { agent, snapshotsDir, scenarios, mode } = options
|
||||
const RECORDING = mode === 'record'
|
||||
const REFRESHING = mode === 'refresh'
|
||||
const childMode: 'replay' | 'record' = RECORDING ? 'record' : 'replay'
|
||||
|
||||
/** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */
|
||||
const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default'
|
||||
@@ -238,15 +333,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
describe(`snapshot: ${scenario.name}`, () => {
|
||||
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the
|
||||
// `authored` ones (sidecar-driven errors/cancel) are never re-recorded.
|
||||
// REFRESH mode is replay-backed and deterministic, so it runs every
|
||||
// scenario and rewrites the comparable fixtures from that replay run.
|
||||
it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => {
|
||||
const dir = join(snapshotsDir, scenario.name)
|
||||
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const childSessions = scenario.childSessions ?? 0
|
||||
const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn
|
||||
const result = await runScenario(input, {
|
||||
agent,
|
||||
mode,
|
||||
mode: childMode,
|
||||
fixtureFile: join(dir, 'session.jsonl'),
|
||||
...existsSync(overrideFile) ? { overrideFile } : {},
|
||||
// In REPLAY, forward the recorded child fixtures so each subagent session
|
||||
@@ -271,30 +369,47 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
}
|
||||
|
||||
// RECORD mode (recorded model scenarios only): persist the freshly-harvested
|
||||
// logs back to their fixtures — the primary to session.jsonl, each child to
|
||||
// session.<n>.jsonl in harvest order. `--update` refreshes the Vitest
|
||||
// goldens but NOT these fixtures, so write them here. A non-pinning
|
||||
// scenario's fixtures are written header-scrubbed, so a re-record can
|
||||
// never smuggle the full prompt/schema content back into every fixture.
|
||||
// live logs back to their fixtures. REFRESH mode does the same from a
|
||||
// keyless replay run for every comparable log, including authored
|
||||
// scenarios that live record deliberately skips. The primary goes to
|
||||
// session.jsonl, each child to session.<n>.jsonl in harvest order. A
|
||||
// non-pinning scenario's fixtures are written header-scrubbed, so a
|
||||
// re-record/refresh can never smuggle the full prompt/schema content
|
||||
// back into every fixture.
|
||||
const scrub = scenario.pinsHeader === true
|
||||
? (log: string): string => log
|
||||
: scrubRequestHeaders
|
||||
if (RECORDING && scenario.recorded && scenario.hasModelTurn) {
|
||||
expect(result.sessionLogs.length, 'record produced no session log to harvest').toBeGreaterThan(0)
|
||||
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
|
||||
const existingFixtures = REFRESHING
|
||||
? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8')))
|
||||
: []
|
||||
const replacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : []
|
||||
const writesSessionFixtures = (RECORDING && scenario.recorded && scenario.hasModelTurn)
|
||||
|| (REFRESHING && comparesLog)
|
||||
if (writesSessionFixtures) {
|
||||
expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0)
|
||||
expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`)
|
||||
.toBe(childSessions + 1)
|
||||
await writeFile(join(dir, 'session.jsonl'), scrub((result.sessionLogs[0] as HarvestedLog).content))
|
||||
const primary = (result.sessionLogs[0] as HarvestedLog).content
|
||||
await writeFile(join(dir, 'session.jsonl'), scrub(
|
||||
REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary,
|
||||
))
|
||||
for (let i = 1; i < result.sessionLogs.length; i++) {
|
||||
await writeFile(join(dir, `session.${i}.jsonl`), scrub((result.sessionLogs[i] as HarvestedLog).content))
|
||||
const child = (result.sessionLogs[i] as HarvestedLog).content
|
||||
await writeFile(join(dir, `session.${i}.jsonl`), scrub(
|
||||
REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
await expect(normalizeStdout(result.rawStdout, ctx))
|
||||
.toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl'))
|
||||
const stdout = normalizeStdout(result.rawStdout, ctx)
|
||||
if (REFRESHING) {
|
||||
await writeFile(join(dir, 'stdout.golden.jsonl'), stdout)
|
||||
}
|
||||
await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl'))
|
||||
|
||||
// A model turn always produces a log worth comparing; a hook scenario can
|
||||
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
|
||||
const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn
|
||||
if (comparesLog) {
|
||||
// The harvested logs (primary-first) must match their committed fixtures
|
||||
// 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS
|
||||
@@ -307,7 +422,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
// reason, and config, but not its bulk content (pinned once, in the
|
||||
// `pinsHeader` scenario).
|
||||
expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1)
|
||||
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
|
||||
for (let i = 0; i < fixtureFiles.length; i++) {
|
||||
const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content)
|
||||
const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8'))
|
||||
@@ -406,17 +520,19 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
}
|
||||
})
|
||||
|
||||
it('every pinning fixture carries exactly one request/header and no deltas', async () => {
|
||||
it('every pinning fixture carries exactly one request/header and its declared deltas', async () => {
|
||||
// The live uniformity guard runs only in NON-pinning scenarios, so a
|
||||
// class made of just its pinning scenario would otherwise accept a
|
||||
// re-recorded pin with several headers or a mid-run header-delta —
|
||||
// shapes the pin design cannot represent. Assert the committed pins
|
||||
// directly.
|
||||
// re-recorded pin with several headers or an undeclared mid-run
|
||||
// header-delta — shapes the pin design cannot represent. Assert the
|
||||
// committed pins directly; a scenario whose arc legitimately rewrites
|
||||
// a prompt section declares the exact count via expectedHeaderDeltas.
|
||||
for (const scenario of pinningByClass.values()) {
|
||||
const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8')
|
||||
const headers = normalizedHeaders(fixture, fixtureContext(fixture))
|
||||
expect(headers.length, `${scenario.name}: a pinning fixture must carry exactly one request/header`).toBe(1)
|
||||
expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry no request/header-delta`).toBe(0)
|
||||
expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared request/header-deltas`)
|
||||
.toBe(scenario.expectedHeaderDeltas ?? 0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +58,13 @@ interface Behavior {
|
||||
strayBucketFile?: boolean
|
||||
/** Delete the sessions root entirely (harvest must yield no logs). */
|
||||
deleteSessionsRoot?: boolean
|
||||
/**
|
||||
* Vocabulary for `session/set_config_option`: allowed values per config id.
|
||||
* A set naming an unknown id or an out-of-vocabulary value rejects (the
|
||||
* real bridge's rule); a valid set answers with the complete refreshed
|
||||
* option state, `currentValue` updated. Absent: every set rejects.
|
||||
*/
|
||||
configOptions?: Record<string, string[]>
|
||||
}
|
||||
|
||||
const sessionsRoot = process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? ''
|
||||
@@ -81,6 +88,8 @@ let sessionCwd = ''
|
||||
let parkedPromptId: number | string | null = null
|
||||
/** Resolvers for permission-probe responses, keyed by outbound request id. */
|
||||
const pendingPermission = new Map<number, (outcome: unknown) => void>()
|
||||
/** Per-run `session/set_config_option` state: config id → current value (first vocabulary entry until set). */
|
||||
const currentConfig: Record<string, string> = {}
|
||||
|
||||
function send(frame: Record<string, unknown>): void {
|
||||
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
|
||||
@@ -195,6 +204,32 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
case 'session/prompt':
|
||||
void handlePrompt(id as number | string)
|
||||
return
|
||||
case 'session/set_config_option': {
|
||||
const vocabulary = behavior.configOptions
|
||||
const configId = params.configId as string
|
||||
const value = params.value as string
|
||||
const values = vocabulary?.[configId]
|
||||
if (values === undefined) {
|
||||
respondError(id as number | string, `unknown config option ${configId}`)
|
||||
return
|
||||
}
|
||||
if (!values.includes(value)) {
|
||||
respondError(id as number | string, `unknown ${configId} value ${value}`)
|
||||
return
|
||||
}
|
||||
currentConfig[configId] = value
|
||||
// The real bridge's contract: every set answers with the COMPLETE
|
||||
// refreshed option state, not just the changed entry.
|
||||
respond(id as number | string, {
|
||||
configOptions: Object.entries(vocabulary as Record<string, string[]>).map(([cid, vs]) => ({
|
||||
id: cid,
|
||||
type: 'select',
|
||||
currentValue: currentConfig[cid] ?? vs[0],
|
||||
options: vs.map(v => ({ value: v, name: v })),
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'session/cancel':
|
||||
if (parkedPromptId !== null) {
|
||||
const parked = parkedPromptId
|
||||
|
||||
@@ -168,6 +168,8 @@ describe('runScenario', () => {
|
||||
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
|
||||
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
|
||||
[{ op: 'cancel' }, /cancel before newSession/],
|
||||
[{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/],
|
||||
[{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/],
|
||||
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
await expect(runScenario(
|
||||
@@ -176,6 +178,53 @@ describe('runScenario', () => {
|
||||
)).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('setConfigOption switches a value and receives the complete refreshed option state', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
configOptions: { 'sandbox-mode': ['read-only', 'workspace-write'], 'approval-policy': ['ask', 'never'] },
|
||||
})
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot,
|
||||
{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'workspace-write' },
|
||||
{ op: 'setConfigOption', configId: 'approval-policy', value: 'never' }],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
// Every set answers with the FULL state: the second response carries the
|
||||
// first switch's value too — the complete-refreshed-state contract.
|
||||
const frames = result.rawStdout.trim().split('\n').map(line => JSON.parse(line) as { result?: { configOptions?: { id: string; currentValue: string }[] } })
|
||||
const states = frames
|
||||
.map(f => f.result?.configOptions)
|
||||
.filter(options => options !== undefined)
|
||||
.map(options => Object.fromEntries((options as { id: string; currentValue: string }[]).map(o => [o.id, o.currentValue])))
|
||||
expect(states).toEqual([
|
||||
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'ask' },
|
||||
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'never' },
|
||||
])
|
||||
})
|
||||
|
||||
it('setConfigOptionExpectError swallows the rejection for unknown ids and out-of-vocabulary values', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot,
|
||||
{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' },
|
||||
{ op: 'setConfigOptionExpectError', configId: 'reasoning-effort', value: 'max' }],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.rawStdout).toContain('unknown sandbox-mode value yolo')
|
||||
expect(result.rawStdout).toContain('unknown config option reasoning-effort')
|
||||
})
|
||||
|
||||
it('setConfigOptionExpectError throws when the set unexpectedly succeeds', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'read-only' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)).rejects.toThrow(/expected set_config_option to be rejected/)
|
||||
})
|
||||
|
||||
it('rejects an unknown input op', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({})
|
||||
const bogus = { op: 'reticulate' } as unknown as InputStep
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { cpSync, mkdtempSync } from 'node:fs'
|
||||
import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { defineAcpSnapshotSuite, type Scenario } from '../src/index.ts'
|
||||
import { childFixturePaths, fixtureContext, headerDeltaCount, normalizedHeaders } from '../src/suite.ts'
|
||||
import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts'
|
||||
import {
|
||||
childFixturePaths,
|
||||
fixtureContext,
|
||||
headerDeltaCount,
|
||||
normalizedHeaders,
|
||||
refreshFixtureReplacements,
|
||||
stabilizeRefreshLog,
|
||||
} from '../src/suite.ts'
|
||||
|
||||
/**
|
||||
* Unit tests for the suite factory, by running it: two synthetic suites over
|
||||
@@ -55,16 +62,40 @@ const RECORD_SCENARIOS: Scenario[] = [
|
||||
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
|
||||
]
|
||||
|
||||
// Record mode mutates its snapshots dir, so run it on a throwaway copy —
|
||||
// except under the documented bootstrap knob, which regenerates the committed
|
||||
// fixtures/goldens in place.
|
||||
// Record/refresh modes mutate their snapshots dir, so run them on throwaway
|
||||
// copies — except record's documented bootstrap knob, which regenerates the
|
||||
// committed record fixtures/goldens in place.
|
||||
const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1'
|
||||
const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-'))
|
||||
if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true })
|
||||
const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-'))
|
||||
cpSync(REPLAY_DIR, refreshDir, { recursive: true })
|
||||
staleRefreshFixtures(refreshDir)
|
||||
afterAll(async () => {
|
||||
if (!BOOTSTRAP) await rm(recordDir, { recursive: true, force: true })
|
||||
await rm(refreshDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function staleRefreshFixtures(dir: string): void {
|
||||
writeFileSync(join(dir, 'plain-turn', 'stdout.golden.jsonl'), 'stale stdout\n')
|
||||
|
||||
const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
|
||||
const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
|
||||
plainBehavior.echoEnv = true
|
||||
writeFileSync(plainBehaviorFile, `${JSON.stringify(plainBehavior, null, 2)}\n`)
|
||||
|
||||
writeFileSync(join(dir, 'blocked-log', 'session.jsonl'), [
|
||||
'{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd"}',
|
||||
'{"type":"hook/result","seq":1,"time":13,"data":{"decision":"stale","durationMs":99}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'authored-error', 'session.jsonl'), [
|
||||
'{"type":"session","id":"77777777-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/error-cwd"}',
|
||||
'{"type":"turn/end","seq":1,"time":9,"data":{"error":"stale"}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
describe('defineAcpSnapshotSuite: replay mode', () => {
|
||||
defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' })
|
||||
})
|
||||
@@ -75,6 +106,27 @@ describe('defineAcpSnapshotSuite: record mode', () => {
|
||||
defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: recordDir, scenarios: RECORD_SCENARIOS, mode: 'record' })
|
||||
})
|
||||
|
||||
describe('defineAcpSnapshotSuite: refresh mode', () => {
|
||||
defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: refreshDir, scenarios: REPLAY_SCENARIOS, mode: 'refresh' })
|
||||
})
|
||||
|
||||
describe('defineAcpSnapshotSuite: refresh write-back', () => {
|
||||
it('rewrites stdout and comparable logs from a replay-mode child run', () => {
|
||||
const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.golden.jsonl'), 'utf8')
|
||||
expect(stdout).not.toContain('stale stdout')
|
||||
expect(stdout).toContain('env:{\\"mode\\":\\"replay\\"')
|
||||
expect(stdout).not.toContain('\\"mode\\":\\"refresh\\"')
|
||||
|
||||
const blocked = readFileSync(join(refreshDir, 'blocked-log', 'session.jsonl'), 'utf8')
|
||||
expect(blocked).toContain('"decision":"block"')
|
||||
expect(blocked).not.toContain('"decision":"stale"')
|
||||
|
||||
const authored = readFileSync(join(refreshDir, 'authored-error', 'session.jsonl'), 'utf8')
|
||||
expect(authored).toContain('"error":"model exploded"')
|
||||
expect(authored).not.toContain('"error":"stale"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineAcpSnapshotSuite: registration contract', () => {
|
||||
it("throws when a scenario's header class has no pinning scenario", () => {
|
||||
expect(() => {
|
||||
@@ -175,3 +227,56 @@ describe('headerDeltaCount', () => {
|
||||
expect(headerDeltaCount(`${other}\n`)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshFixtureReplacements', () => {
|
||||
it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => {
|
||||
const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content })
|
||||
const logs = [
|
||||
log('{"type":"session","id":"","cwd":"/same"}\n'),
|
||||
log('{"type":"session","id":"new-parent","cwd":"/new"}\n'),
|
||||
log('{"type":"session","id":"new-child","cwd":"/new"}\n'),
|
||||
]
|
||||
const fixtures = [
|
||||
'{"type":"session","id":"","cwd":"/same"}\n',
|
||||
'{"type":"session","id":"old-parent","cwd":"/old"}\n',
|
||||
]
|
||||
expect(refreshFixtureReplacements(logs, fixtures)).toEqual([
|
||||
{ from: 'new-parent', to: 'old-parent' },
|
||||
{ from: '/new', to: '/old' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',
|
||||
'{"type":"hook/result","seq":1,"time":22,"data":{"decision":"block","durationMs":37}}',
|
||||
'{"type":"turn/end","seq":2,"time":33,"data":{"error":"fresh error"}}',
|
||||
'{"type":"tool/result","seq":3,"time":44,"data":{"text":"new-parent in /new"}}',
|
||||
'{"type":"hook/result","seq":4,"time":55,"data":{"decision":"allow","durationMs":5}}',
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"old-child","createdAt":100,"cwd":"/old","parentSession":"old-parent","seedLength":5}',
|
||||
'{"type":"hook/result","seq":1,"time":11,"data":{"decision":"stale","durationMs":99}}',
|
||||
'{"type":"turn/end","seq":2,"data":{"error":"stale"}}',
|
||||
'{"type":"assistant/message","seq":3,"time":12,"data":{"text":"different type"}}',
|
||||
'{"type":"hook/result","seq":4,"time":13,"data":{"decision":"stale"}}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [
|
||||
{ from: 'new-parent', to: 'old-parent' },
|
||||
{ from: 'new-child', to: 'old-child' },
|
||||
{ from: '/new', to: '/old' },
|
||||
])).toBe([
|
||||
'{"type":"session","id":"old-child","createdAt":100,"cwd":"/old","parentSession":"old-parent","seedLength":1}',
|
||||
'{"type":"hook/result","seq":1,"time":11,"data":{"decision":"block","durationMs":99}}',
|
||||
'{"type":"turn/end","seq":2,"time":33,"data":{"error":"fresh error"}}',
|
||||
'{"type":"tool/result","seq":3,"time":44,"data":{"text":"old-parent in /old"}}',
|
||||
'{"type":"hook/result","seq":4,"time":13,"data":{"decision":"allow","durationMs":5}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
@@ -13,6 +14,6 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
|
||||
|
||||
`user-interaction` and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam remains provider-neutral (`ctx.userInteraction`), while the tool is the model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-acp-agent
|
||||
|
||||
The **ACP server app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The ACP server app: the providerless agent spine ({@link
|
||||
* The ACP server app: the default agent spine ({@link
|
||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP
|
||||
* server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp}
|
||||
* bridge, and DELIBERATELY NOTHING that writes to stdout.
|
||||
@@ -60,6 +60,8 @@ export interface Config {
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -71,6 +73,7 @@ export const Config: z<Config> = z.object({
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -85,6 +88,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import * as acpAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -23,9 +27,47 @@ async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-acp-agent composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
@@ -44,12 +86,30 @@ describe('dsh-acp-agent composition', () => {
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { model: 'mock' })
|
||||
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-agent')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
@@ -72,7 +132,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ async function makeConsumer(): Promise<string> {
|
||||
' name: \'@deepseek-ai/dsh-acp-agent\'',
|
||||
' config:',
|
||||
' model: deepseek-v4-flash',
|
||||
' systemPrompt: \'test agent\'',
|
||||
' persona: \'test agent\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
@@ -120,7 +120,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(consumer, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(consumer, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const stderr: string[] = []
|
||||
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||
cwd,
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
|
||||
@@ -54,7 +54,7 @@ const CORDIS_YML = `
|
||||
name: '@deepseek-ai/dsh-acp-agent'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
systemPrompt: 'You are a test agent.'
|
||||
persona: 'You are a test agent.'
|
||||
`
|
||||
|
||||
interface Spawned {
|
||||
@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
// Key-present check only; no prompt is sent, so the model is never called.
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
@@ -31,10 +31,16 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
|
||||
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
|
||||
## Multi-session
|
||||
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
|
||||
|
||||
## Session config options
|
||||
|
||||
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
|
||||
|
||||
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
|
||||
|
||||
@@ -67,18 +73,21 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
|
||||
|
||||
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
|
||||
|
||||
## Permission prompts
|
||||
|
||||
The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority.
|
||||
|
||||
## Disposal & disconnect
|
||||
|
||||
Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise).
|
||||
|
||||
## Known limitations (tracked TODOs)
|
||||
|
||||
- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
|
||||
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session sandbox/approval config options. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
@@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes not modeled (see [§6 Modes](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
|
||||
| model selection | S | ❌ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
|
||||
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
|
||||
@@ -39,7 +39,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| Method | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). |
|
||||
| `session/request_permission` | S | ❌ | ✅ | ✅ | **The biggest gap.** Tools run with the executor's full authority; no user authorization round-trip. The `agent→sessionId` reverse map is already in place to route a future permission request. Tracked `TODO(rfc010-permission-gate)`. |
|
||||
| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../user-approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). |
|
||||
| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. |
|
||||
| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. |
|
||||
| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). |
|
||||
@@ -86,7 +86,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
|
||||
|
||||
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it.
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
@@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
|
||||
| Feature | Stable | Bridge | Notes |
|
||||
|---|---|---|---|
|
||||
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
|
||||
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
|
||||
| Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. |
|
||||
@@ -140,15 +140,14 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
|
||||
|
||||
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
|
||||
|
||||
1. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
|
||||
2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
|
||||
3. **Modes / config options / model selection** — coupled to the permission gate.
|
||||
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
5. **Slash commands** (`available_commands_update`).
|
||||
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
|
||||
2. **Model selection** — sandbox and approval config options are implemented; selecting the bridge's model at runtime remains open.
|
||||
3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
4. **Slash commands** (`available_commands_update`).
|
||||
5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
6. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
7. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
8. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -38,10 +41,14 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
|
||||
* `session/event` and `agent/*` event is routed strictly to its owning session
|
||||
* record, so two sessions streaming at once never interleave their
|
||||
* `session/update` notifications. The `tools/pre-execute` permission gate is
|
||||
* deferred — see the TODO(rfc010-permission-gate) note below.
|
||||
* `session/update` notifications. Permission prompts ride the same ownership
|
||||
* map: the bridge answers `approval/request` for its own agents over
|
||||
* `session/request_permission` (see the approval answerer below) — whether a
|
||||
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
|
||||
*
|
||||
* stdout is the protocol: this plugin must run in an example that loads NO
|
||||
* stdout logger (the console logger writes to stdout and would corrupt the
|
||||
@@ -60,7 +62,10 @@ import {
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type Stream,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
@@ -69,11 +74,18 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
// Side-effect type import: declaration-merges the `approval/request` waterfall
|
||||
// the bridge answers for its own agents (see the approval answerer below).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
@@ -308,6 +320,19 @@ interface SessionRecord {
|
||||
turn: number | undefined
|
||||
logWatermark: number
|
||||
} | undefined
|
||||
/**
|
||||
* Config switches accepted while the session was IDLE, not yet anchored in
|
||||
* its log. The turn-enclosure contract makes a bare between-turns append
|
||||
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
|
||||
* garbage, and dev invariants throw), so an idle switch waits here and is
|
||||
* anchored at the next turn's prompt-submit — before anything in that
|
||||
* turn assembles a prompt or runs a call, and last write
|
||||
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
|
||||
* the switch lives only in bridge memory: the set/new/load responses
|
||||
* overlay it truthfully, and a restart before the next turn reverts it —
|
||||
* which `session/load` then reports honestly from the log's fold.
|
||||
*/
|
||||
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,8 +580,136 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
|
||||
})
|
||||
|
||||
// --- Approval answerer -----------------------------------------------------
|
||||
// The bridge is the approval channel for the agents it owns: an `ask` routed
|
||||
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
|
||||
// an editor permission prompt attached to the already-streamed tool call. The
|
||||
// listener occupies the single decision slot ONLY for its own agents — a
|
||||
// foreign or call-less request delegates via next() so another answerer (or
|
||||
// the fail-closed `unavailable` default) takes the question. A rejected
|
||||
// `requestPermission` (client gone, bridge torn down) propagates and the
|
||||
// ApprovalService contains it as `unavailable`. Options are one-shot only:
|
||||
// allow_always is a grant-storage design the approval RFC defers, so the
|
||||
// prompt never offers a durable grant the harness could not honor.
|
||||
ctx.on('approval/request', (req, next) => {
|
||||
const sessionId = bySession.get(req.agent)
|
||||
// The protocol requires `toolCall` (the prompt renders attached to it), so
|
||||
// a request without a callId has nothing to attach to — delegate.
|
||||
if (sessionId === undefined || req.callId === undefined) return next()
|
||||
return conn.requestPermission({
|
||||
sessionId,
|
||||
toolCall: { toolCallId: req.callId },
|
||||
options: [
|
||||
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
}).then(({ outcome }) => {
|
||||
if (outcome.outcome === 'cancelled') return 'cancelled'
|
||||
// Only the two advertised options exist; an unknown optionId from a
|
||||
// non-conforming client counts as a rejection, never a grant.
|
||||
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* The session config options this composition can honor, with current
|
||||
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
|
||||
* `effectiveApprovalPolicy` — the log is the per-session store, so a
|
||||
* `session/load` reports a resumed session's overrides with no catch-up
|
||||
* machinery), overlaid with the record's not-yet-anchored pending switches
|
||||
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: the sandbox option exists only when the mounted
|
||||
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
|
||||
* option only when the approval seam is composed — both read
|
||||
* opportunistically so this bridge keeps working in compositions without
|
||||
* them.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
const options: SessionConfigOption[] = []
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode !== undefined) {
|
||||
options.push({
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
|
||||
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
|
||||
})
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval !== undefined) {
|
||||
options.push({
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
// `?? 'ask'` also shields against a provided stand-in whose config
|
||||
// never went through the plugin schema (tests do this).
|
||||
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
|
||||
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's log currently has an open turn — the last boundary
|
||||
* event is a `turn/start`. Decides whether a config switch may append NOW
|
||||
* (enclosed) or must wait for the next turn (see
|
||||
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
|
||||
* `agent.status`: status stays `running` across the gap between two queued
|
||||
* turns, where a bare append would still land outside any turn.
|
||||
*/
|
||||
const isTurnOpen = (agent: Agent): boolean => {
|
||||
const events = agent.session.events
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const type = (events[index] as SessionEvent).type
|
||||
if (type === 'turn/start') return true
|
||||
if (type === 'turn/end') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a record's pending switches into its (just-opened) turn, last
|
||||
* write per knob — skipping a value the session already effectively has,
|
||||
* so a net-zero idle flip-flop anchors NOTHING (the log records switches,
|
||||
* not select clicks).
|
||||
*/
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
const events = rec.agent.session.events
|
||||
if (pending.sandboxMode !== undefined
|
||||
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
|
||||
setSandboxMode(rec.agent.session, pending.sandboxMode)
|
||||
}
|
||||
if (pending.approvalPolicy !== undefined
|
||||
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
|
||||
// is open (the seam fires inside it, per drained message — the first flush
|
||||
// empties the slot, later ones no-op), the loop has not yet assembled
|
||||
// anything for it, and — unlike appending from inside a `session/event`
|
||||
// listener — this seam fires OUTSIDE any log emit, so peer listeners
|
||||
// (the dev invariants, persistence) observe the anchored events in strict
|
||||
// log order. A turn with no prompt (an idle inject's one-shot injection
|
||||
// turn) leaves the switch pending — it runs no step, so nothing executes
|
||||
// or assembles under a stale value.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
|
||||
const sessionId = bySession.get(agent)
|
||||
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
|
||||
if (rec !== undefined) flushPendingSwitches(rec)
|
||||
return next()
|
||||
})
|
||||
|
||||
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
|
||||
conn = connection
|
||||
return {
|
||||
@@ -610,8 +763,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
presenter: makePresenter(),
|
||||
terminalEnabled: terminalOutputCap,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
})
|
||||
return Promise.resolve({ sessionId })
|
||||
const configOptions = configOptionsFor(handle.agent)
|
||||
return Promise.resolve({ sessionId, ...configOptions.length > 0 ? { configOptions } : {} })
|
||||
},
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
@@ -687,6 +842,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
presenter: makePresenter(),
|
||||
terminalEnabled,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
// Replay the persisted event log to the client as session/update. Use
|
||||
@@ -710,7 +866,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
for (const event of agent.session.events) {
|
||||
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
|
||||
}
|
||||
return {}
|
||||
const configOptions = configOptionsFor(agent)
|
||||
return configOptions.length > 0 ? { configOptions } : {}
|
||||
} finally {
|
||||
loadingIds.delete(sessionId)
|
||||
}
|
||||
@@ -765,6 +922,62 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
settlePrompt(rec, 'cancelled')
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
// Both advertised options are selects, so the boolean-shaped variant of
|
||||
// the request is a protocol misuse regardless of configId.
|
||||
if (typeof params.value !== 'string') {
|
||||
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
|
||||
}
|
||||
// The setters append ONE log-only event on this session's own log —
|
||||
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
|
||||
// prompt section, and the narrator all fold it from there, and a
|
||||
// resumed session reports the override back through
|
||||
// configOptionsFor. A switch while a turn is OPEN anchors
|
||||
// immediately (the next step sees it); an IDLE switch waits in
|
||||
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
|
||||
// between-turns append would be dropped as crash tail on reload).
|
||||
// Values are validated against the same closed lists the options
|
||||
// advertised; an id this composition never advertised (or an unknown
|
||||
// one) rejects.
|
||||
switch (params.configId) {
|
||||
case 'sandbox-mode': {
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
|
||||
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as SandboxMode
|
||||
// A no-op switch (the value the session already shows — pending,
|
||||
// else fold, else default) is acknowledged without recording
|
||||
// anything: clients that re-push current selections on session
|
||||
// start must not mint override events out of thin air.
|
||||
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
|
||||
else rec.pendingSwitches.sandboxMode = value
|
||||
break
|
||||
}
|
||||
case 'approval-policy': {
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
|
||||
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as ApprovalPolicy
|
||||
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
|
||||
else rec.pendingSwitches.approvalPolicy = value
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw invalidParams(`unknown config option ${JSON.stringify(params.configId)}`)
|
||||
}
|
||||
// The spec requires the COMPLETE refreshed config state in the response
|
||||
// (a change may cascade); ours are independent, but the contract holds.
|
||||
return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The bridge's `approval/request` answerer: an ask for an agent the bridge
|
||||
* owns becomes a `session/request_permission` prompt attached to the tool
|
||||
* call; foreign or call-less requests delegate down to the fail-closed
|
||||
* default. Driven through `ctx.approval` — the same path dsh-tools' ask
|
||||
* routing takes — against the harness's scriptable client.
|
||||
*/
|
||||
describe('acp bridge — approval answerer', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function ownedAgentRequest(
|
||||
h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
|
||||
): Promise<{ agent: Agent; request: ApprovalRequest }> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.get(AgentId(sessionId))
|
||||
if (agent === undefined) throw new Error('newSession created no agent')
|
||||
// In production an ask always fires mid-turn (tool execution); open one so
|
||||
// request()'s turn-enclosure precondition holds for the direct drive below.
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
|
||||
}
|
||||
|
||||
it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
|
||||
expect(harness.permissionRequests).toHaveLength(1)
|
||||
const wire = harness.permissionRequests[0]
|
||||
expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
|
||||
expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
|
||||
{ optionId: 'allow-once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', kind: 'reject_once' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps reject-once → rejected', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('maps a client cancellation → cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
|
||||
})
|
||||
|
||||
it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('delegates a foreign agent down to the fail-closed default', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
// Not created through the bridge: no bySession entry, so the answerer must
|
||||
// call next() — nobody else answers, so the seam fails closed.
|
||||
const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { agent } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Session config options over the bridge: the two per-session knobs
|
||||
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
|
||||
* their current values folded from each session's own log, switching via
|
||||
* `session/set_config_option` (one log-only event per switch — the log is the
|
||||
* store), and a resumed session reporting its overrides back on
|
||||
* `session/load` with no catch-up machinery.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The REAL local executor reporting a confining default — `sandboxMode` is
|
||||
* the documented capability override point (`dsh-bash-sandbox` overrides it
|
||||
* the same way), so the bridge sees exactly what a sandboxing composition
|
||||
* advertises without this suite dragging in a kernel sandbox stack.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'read-only'
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact option payloads the bridge advertises (pinned verbatim). */
|
||||
function sandboxOption(currentValue: SandboxMode): object {
|
||||
return {
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function approvalOption(currentValue: ApprovalPolicy): object {
|
||||
return {
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'ask', name: 'ask' },
|
||||
{ value: 'never', name: 'never' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) })
|
||||
afterEach(async () => {
|
||||
if (h) await h.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
h = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
|
||||
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// The dev invariants police turn-enclosure: an idle switch that appended
|
||||
// outside a turn would throw right here in the suite, not in production.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions in a composition with neither knob', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, withBash: true })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('advertises both knobs with capability-derived currents (config default included)', async () => {
|
||||
h = await bothKnobs({ policy: 'never' })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
|
||||
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
|
||||
// (the dev invariants in this suite would throw), so the switch lives on
|
||||
// the record until a turn opens.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
// The next turn anchors both switches inside itself, one event per knob.
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
|
||||
// switch pends rather than appending outside the closed turn.
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Re-pushing the composition default (what clients that echo current
|
||||
// selections on session start do) must not mint an override event.
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
|
||||
// Re-sending a PENDING value keeps the pending switch alive (it is what
|
||||
// the session shows), rather than cancelling it.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy')).toHaveLength(0)
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
|
||||
h = await bothKnobs({ script: ['hang'] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
// Give the loop a tick to open the turn (the turns.spec hang idiom).
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
})
|
||||
|
||||
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
const sessionId = res.sessionId
|
||||
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions).toEqual([approvalOption('ask')])
|
||||
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
})
|
||||
|
||||
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// sandbox-mode exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
|
||||
.rejects.toThrow(/unknown sandbox-mode value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
|
||||
.rejects.toThrow(/unknown approval-policy value/)
|
||||
})
|
||||
|
||||
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
|
||||
h = await bothKnobs()
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
// B sees its own composition defaults, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
// ...and A keeps its own state, untouched by B's.
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s overrides from its own log', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
// One turn checkpoints the log (the switch events flush with it).
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await bothKnobs()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-stdio-agent
|
||||
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
|
||||
It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
@@ -11,7 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
@@ -32,6 +32,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The stdio chat app: the providerless agent spine ({@link
|
||||
* The stdio chat app: the default agent spine ({@link
|
||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
|
||||
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
|
||||
* module), JSONL session
|
||||
@@ -58,7 +58,9 @@ export const name = 'stdio-agent'
|
||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for the `main` agent (must have a registered adapter). */
|
||||
@@ -73,6 +75,8 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -91,6 +95,7 @@ export const Config: z<Config> = z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
})
|
||||
|
||||
@@ -110,8 +115,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
agents: [{
|
||||
id: AgentId('main'),
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(UserInteractionService)
|
||||
|
||||
@@ -92,7 +92,7 @@ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promi
|
||||
' name: \'@deepseek-ai/dsh-stdio-agent\'',
|
||||
' config:',
|
||||
' model: mock-echo',
|
||||
' systemPrompt: \'demo\'',
|
||||
' persona: \'demo\'',
|
||||
` welcome: '${welcome}'`,
|
||||
...disabledBrokenEntry
|
||||
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
|
||||
@@ -111,7 +111,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
@@ -30,9 +34,47 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -40,7 +82,9 @@ describe('dsh-stdio-agent app', () => {
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||
// The pre-created `main` agent the UI drives.
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const agent = ctx.get('agents')?.get(AgentId('main'))
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -51,13 +95,24 @@ describe('dsh-stdio-agent app', () => {
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||
// A resume id defers agent creation until persistence loads; with no backing
|
||||
// session the resume is contained + logged, so no `main` agent registers —
|
||||
@@ -67,11 +122,19 @@ describe('dsh-stdio-agent app', () => {
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
@@ -94,7 +157,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# @deepseek-ai/dsh-user-approval
|
||||
|
||||
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
|
||||
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
|
||||
|
||||
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
|
||||
|
||||
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
|
||||
|
||||
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).
|
||||
|
||||
Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-user-approval",
|
||||
"description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.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-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Approval seam: `ctx.approval` answers exactly one question — "may this
|
||||
* specific action proceed?" — by dispatching the `approval/request` waterfall
|
||||
* to whatever answerers the deployment composed (an ACP editor prompt, an
|
||||
* auto-decide policy, a scripted test listener) and returning a closed
|
||||
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
|
||||
* built-in default `'unavailable'`: absence of a UI can never grant anything.
|
||||
*
|
||||
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
|
||||
* the POLICY. It serves both ask paths the sandbox RFC names — the
|
||||
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
|
||||
* so every asker shares one outcome
|
||||
* vocabulary and one audit trail. Grants are one-shot by design: an
|
||||
* `'allowed-once'` outcome authorizes the single action it was asked about,
|
||||
* never a class of future actions.
|
||||
*
|
||||
* Every request lands two log-only session events on the requesting agent's
|
||||
* log (`approval/asked` / `approval/decided`, paired by
|
||||
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
|
||||
* model-visible transcript: the model only ever sees the tool result the
|
||||
* caller derives from the outcome.
|
||||
*
|
||||
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
|
||||
* `effective = fold(the session's 'approval/policy' events, last one wins)
|
||||
* ?? config.policy` — the session log is the store, so an override survives
|
||||
* restart by replay. The service resolves `'never'` sessions to
|
||||
* `'rejected'` inside `request()` before dispatching any answerer (no
|
||||
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
|
||||
* (and only `'never'` — an availability promise is unknowable without
|
||||
* asking); an `agent/pre-step` narrator explains a switch to the model in at
|
||||
* most one coalesced notice per step.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-user-approval
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
approval: ApprovalService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall asking the composed answerers to decide one approval request.
|
||||
* Dispatched only from {@link ApprovalService.request} — callers go through
|
||||
* the service (which owns cancellation and the audit events), never through
|
||||
* `ctx.waterfall` directly. A listener that can answer for this request's
|
||||
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
|
||||
* single-occupancy, first listener to answer wins); a listener that does
|
||||
* not recognize the agent MUST call `next()` so another answerer — or the
|
||||
* fail-closed default `'unavailable'` — gets the question. Throwing is
|
||||
* contained by the service and yields `'unavailable'`.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* An approval question was put to the answerer chain — log-only audit
|
||||
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
|
||||
* it with the `approval/decided` that always follows; `toolName` is the
|
||||
* tool the question is about, `callId` the exact tool call when the asker
|
||||
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
|
||||
* permission-decision reason).
|
||||
*/
|
||||
'approval/asked': {
|
||||
id: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
}
|
||||
/**
|
||||
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
|
||||
* Exactly one per ask, appended when the outcome is known: a decision, a
|
||||
* cancellation, or the fail-closed `'unavailable'`.
|
||||
*/
|
||||
'approval/decided': {
|
||||
id: ApprovalRequestId
|
||||
outcome: ApprovalOutcome
|
||||
}
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* from the prompt section and the narrator's notices). The LAST such
|
||||
* event is the session's override ({@link effectiveApprovalPolicy});
|
||||
* who asked for it is derivable from position (an event after the log's
|
||||
* last `request/header*` was a runtime switch by the user).
|
||||
*/
|
||||
'approval/policy': { policy: ApprovalPolicy }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
* Service-issued (one fresh id per {@link ApprovalService.request} call).
|
||||
*/
|
||||
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link ApprovalRequestId}.
|
||||
* @param id - the raw id string to brand.
|
||||
* @returns the same string carrying the brand.
|
||||
*/
|
||||
export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
return id as ApprovalRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one approval request.
|
||||
*
|
||||
* - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
|
||||
* consumed by proceeding, never a durable authorization.
|
||||
* - `'rejected'` — an answerer (human or policy) said no.
|
||||
* - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
|
||||
* the requesting execution aborted while the question was pending.
|
||||
* - `'unavailable'` — nobody composed could answer (no listener, none that
|
||||
* recognizes the agent, or an answerer failed). Callers MUST fail closed on
|
||||
* it, exactly like `'rejected'` — the two differ only for audit and wording.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
|
||||
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
|
||||
|
||||
/**
|
||||
* A session's approval policy — what happens to an {@link ApprovalService}
|
||||
* ask BEFORE any interactive answerer sees it:
|
||||
*
|
||||
* - `'ask'` (the default) — delegate to the composed answerers; with none
|
||||
* composed the chain falls through to the fail-closed `'unavailable'`
|
||||
* (exactly today's behavior).
|
||||
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
|
||||
* deterministically. The strict headless stance (CI, unattended runs) and
|
||||
* the only policy value stated in the system prompt — unlike `'ask'`, its
|
||||
* outcome is knowable without asking, so stating it cannot overclaim.
|
||||
*/
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
|
||||
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
|
||||
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
|
||||
|
||||
/**
|
||||
* The prompt sentence stating a `'never'` policy — visibility for the one
|
||||
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
|
||||
* does NOT parse this prose: deployments can quote it in a persona or another
|
||||
* section, so the section also emits a source-owned marker.
|
||||
*/
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
|
||||
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
|
||||
const POLICY_MARKERS = {
|
||||
ask: '<!-- dsh-user-approval-policy:ask -->',
|
||||
never: '<!-- dsh-user-approval-policy:never -->',
|
||||
} as const satisfies Record<ApprovalPolicy, string>
|
||||
|
||||
/**
|
||||
* Read the policy fact emitted by this service from a logged system prompt.
|
||||
* The section is ordered after deployment persona text, and the last marker
|
||||
* wins so a persona quoting an earlier marker cannot shadow the service's own
|
||||
* contribution. Ordinary policy prose is deliberately ignored.
|
||||
*/
|
||||
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
|
||||
if (system === undefined) return undefined
|
||||
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
|
||||
const never = system.lastIndexOf(POLICY_MARKERS.never)
|
||||
if (ask < 0 && never < 0) return undefined
|
||||
return never > ask ? 'never' : 'ask'
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's approval-policy override: the last `approval/policy` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the policy of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'approval/policy') return event.data.policy
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the log currently sits inside an open turn (a `turn/start` not yet
|
||||
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
|
||||
* The audit pair must be turn-enclosed: the turn is the durable log's
|
||||
* commit/replay boundary, so a bare event appended between turns is
|
||||
* indistinguishable from a crash tail and silently dropped on reload.
|
||||
*/
|
||||
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const type = (events[index] as SessionEvent).type
|
||||
if (type === 'turn/start') return true
|
||||
if (type === 'turn/end') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's approval-policy override: appends exactly
|
||||
* one `approval/policy` event — the switch IS its event; nothing mutates
|
||||
* policy state out of band. Takes effect on the session's next ask and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* @param session - the session the override belongs to.
|
||||
* @param policy - the policy every subsequent ask for this session resolves
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
|
||||
session.append('approval/policy', { policy })
|
||||
}
|
||||
|
||||
/**
|
||||
* One concrete permission question. Identifies the action precisely enough
|
||||
* for an answerer to present it and for the audit events to reconstruct what
|
||||
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
|
||||
* attaches the prompt to the already-streamed tool call via `callId` instead
|
||||
* of re-rendering the call.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's default {@link ApprovalPolicy} for sessions without an
|
||||
* `approval/policy` override — `'ask'` delegates to the composed answerers
|
||||
* (fail-closed with none); `'never'` auto-rejects every ask without
|
||||
* prompting (the deterministic CI/unattended stance).
|
||||
*/
|
||||
policy?: ApprovalPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
|
||||
* `approval/request` waterfall and audits every ask/outcome pair to the
|
||||
* requesting agent's session log. Stateless between requests — grants are
|
||||
* returned to the caller, never stored here.
|
||||
*
|
||||
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
|
||||
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
|
||||
* before dispatching any interactive answerer, a per-agent prompt section
|
||||
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
|
||||
* could overclaim an answerer that headless compositions do not have), and an
|
||||
* `agent/pre-step` narrator injects at most one coalesced notice when a
|
||||
* session's effective policy moved past what the model was last told.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
policy: z.union(['ask', 'never'] as const).default('ask'),
|
||||
})
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'approval')
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
|
||||
|
||||
// Visibility layer 1, scoped on the prompt registry so headless
|
||||
// compositions mount the seam without it: state the one deterministic
|
||||
// policy per session. 'ask' renders only a source-owned state marker —
|
||||
// stating "you will be asked" would overclaim in a composition with no
|
||||
// answerer. The marker, not deployment-controlled prose, is what the
|
||||
// restart narrator reads back from the logged request header.
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.section({
|
||||
name: 'approval:policy',
|
||||
order: 115,
|
||||
text: (context) => {
|
||||
const agent = context.agent
|
||||
// A bare assemble() (tests, diagnostics) has no session to state.
|
||||
if (agent === undefined) return ''
|
||||
const policy = effective(agent)
|
||||
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
|
||||
// assembly but before the request history is derived, so the notice is
|
||||
// seen by THIS step's request: idle-time flip-flops coalesce at the
|
||||
// turn's first step (net-zero → nothing), and a mid-turn switch is
|
||||
// narrated no later than the next step. What each session was last told
|
||||
// is in-memory with a log-derived fallback (the folded header's system
|
||||
// text), so restarts lose nothing. Attribution is positional: an
|
||||
// override event after the log's last `request/header*` was a runtime
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
ctx.on('agent/pre-step', (agent) => {
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
let headerIndex = -1
|
||||
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
|
||||
const event = events[index] as (typeof events)[number]
|
||||
if (overrideIndex < 0 && event.type === 'approval/policy') {
|
||||
overrideIndex = index
|
||||
} else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) {
|
||||
headerIndex = index
|
||||
}
|
||||
}
|
||||
// Same fold effectivePolicy performs — override is scanned here anyway
|
||||
// for POSITIONAL attribution; the default lives once, in the method.
|
||||
const current = this.effectivePolicy(agent)
|
||||
const header = session.requestHeader()
|
||||
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
|
||||
narrated.set(session, current)
|
||||
// Cold start (nothing ever told) narrates nothing — the section about
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
{ source: { kind: 'plugin', plugin: 'user-approval' } },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the composed answerers to decide one request. Requires an open turn
|
||||
* on the requesting agent's session — the audit pair below is turn-enclosed
|
||||
* by contract (the turn is the log's commit/replay boundary; an idle append
|
||||
* would be dropped as crash tail on reload) — and throws before appending
|
||||
* anything when called idle; asking outside a turn is a deferred design.
|
||||
* Within that precondition it always resolves to an outcome, never rejects:
|
||||
* an aborted signal yields `'cancelled'`, a missing or throwing answerer
|
||||
* yields `'unavailable'` (fail closed), and a rogue non-vocabulary return
|
||||
* value is normalized to `'unavailable'`. Appends the
|
||||
* `approval/asked`/`approval/decided` audit pair (log-only) around the
|
||||
* decision regardless of outcome.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @returns the closed outcome; `'allowed-once'` is the only grant.
|
||||
*/
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (!hasOpenTurn(req.agent.session.events)) {
|
||||
throw new Error(
|
||||
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
|
||||
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
|
||||
+ 'Ask from inside the turn that needs the decision.',
|
||||
)
|
||||
}
|
||||
const id = ApprovalRequestId(randomUUID())
|
||||
req.agent.session.append('approval/asked', {
|
||||
id,
|
||||
toolName: req.toolName,
|
||||
...req.callId !== undefined ? { callId: req.callId } : {},
|
||||
...req.reason !== undefined ? { reason: req.reason } : {},
|
||||
})
|
||||
const outcome = await this.decide(req)
|
||||
req.agent.session.append('approval/decided', { id, outcome })
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's effective policy: its own `approval/policy` fold, else the
|
||||
* configured default (the schema already defaulted an omitted policy to
|
||||
* `'ask'`; the `??` only narrows the optional-input TYPE).
|
||||
* @param agent - the agent whose session's policy applies.
|
||||
* @returns the policy every ask for this agent resolves under right now.
|
||||
*/
|
||||
private effectivePolicy(agent: Agent): ApprovalPolicy {
|
||||
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/** Dispatch the waterfall, contained and raced against `req.signal`. */
|
||||
private async decide(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (req.signal?.aborted) return 'cancelled'
|
||||
// The 'never' policy is decided HERE, before any dispatch: a listener
|
||||
// registered with `prepend: true` after this service mounts would sit
|
||||
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
|
||||
// documented promise that 'never' rejects deterministically regardless
|
||||
// of registration order — only the service's own request path can.
|
||||
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
|
||||
// Enter the promise chain BEFORE dispatching: a listener that throws
|
||||
// SYNCHRONOUSLY (before its first await) must land in the same rejection
|
||||
// path as an async one — `Promise.resolve(call())` would let it escape
|
||||
// the containment into the caller.
|
||||
const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
|
||||
() => this.ctx.waterfall(this, 'approval/request', req, () => Promise.resolve<ApprovalOutcome>('unavailable')),
|
||||
).then(
|
||||
// Normalize a rogue (non-vocabulary) answerer return to the fail-closed
|
||||
// outcome instead of leaking it into callers' closed-union switches.
|
||||
outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
|
||||
// A throwing answerer must fail the QUESTION closed, not the caller's
|
||||
// tool call open — the seam contains its callbacks.
|
||||
() => 'unavailable',
|
||||
)
|
||||
const signal = req.signal
|
||||
if (signal === undefined) return answer
|
||||
return await new Promise<ApprovalOutcome>((resolve) => {
|
||||
const onAbort = () => { resolve('cancelled') }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void answer.then((outcome) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
// After an abort won the race this resolve is a settled-promise no-op:
|
||||
// the late answer is discarded by construction.
|
||||
resolve(outcome)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ApprovalService
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user