# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/bash.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-07-06-sandbox.md # docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md # examples/AGENTS.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md # examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md # examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md # examples/coding-agent/cordis.yml # examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl # packages/bash/bash-local/README.md # packages/bash/bash-local/src/index.ts # packages/bash/bash-local/tests/executor.spec.ts # packages/bash/bash-sandbox/README.md # packages/bash/bash-sandbox/src/index.ts # packages/bash/bash/README.md # packages/bash/bash/src/index.ts # packages/bash/bash/src/types.ts # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/README.md # packages/core/agent-core/src/index.ts # packages/subagent/subagent/README.md # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # packages/ui/acp-agent/README.md # packages/ui/acp/README.md # packages/ui/stdio-agent/README.md # packages/util/brand/src/index.ts # scripts/doc-budgets.manifest.json
224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
# Bash Executor
|
|
|
|
The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle.
|
|
|
|
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
|
|
|
|
## Request vs. spec: the `resolve()` split
|
|
|
|
The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`, filled from config) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory came from.
|
|
|
|
```ts type-equiv
|
|
interface BashExecRequest {
|
|
command: string
|
|
/** Working directory override (default: implementation-configured). */
|
|
workdir?: string | undefined
|
|
/** Timeout override in milliseconds (implementations cap it). */
|
|
timeoutMs?: number | undefined
|
|
/** Abort signal — implementations kill the command when it fires. */
|
|
signal?: AbortSignal | undefined
|
|
/**
|
|
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
|
|
* closed/empty (the default for model-driven tool calls). Set by in-process
|
|
* plugins (e.g. the hooks bridges, which write a hook command's JSON payload
|
|
* to its stdin); the model-facing bash tool does not expose it as a parameter
|
|
* (a model that needs stdin uses shell syntax like a heredoc or a pipe).
|
|
*/
|
|
stdin?: string | undefined
|
|
/**
|
|
* Extra environment entries for the command, merged AFTER the
|
|
* implementation's credential scrub (so an explicit entry here is honored even
|
|
* when its name matches the scrub pattern — the caller named a value it holds,
|
|
* not the harness's ambient secret). Set by in-process plugins (the hooks
|
|
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
|
|
* bash tool does not expose it as a parameter (a model that needs an env var
|
|
* uses shell syntax like `FOO=bar cmd`).
|
|
*/
|
|
env?: Record<string, string> | 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
|
|
}
|
|
```
|
|
|
|
```ts type-equiv
|
|
interface BashExecSpec {
|
|
command: string
|
|
workdir: string
|
|
timeoutMs: number
|
|
/** Abort signal — implementations kill the command when it fires. */
|
|
signal?: AbortSignal | undefined
|
|
/**
|
|
* Bytes to write to the command's stdin (then close it), carried through
|
|
* verbatim from {@link BashExecRequest.stdin}. It has no config default, so
|
|
* a missing value means "no stdin" and remains an ordinary optional.
|
|
*/
|
|
stdin?: string | undefined
|
|
/**
|
|
* Extra environment entries, carried through verbatim from
|
|
* {@link BashExecRequest.env} and merged by the implementation AFTER its
|
|
* credential scrub (an explicit entry wins even when its name matches the
|
|
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
|
|
* config default, absent means "no extra env".
|
|
*/
|
|
env?: Record<string, string> | undefined
|
|
/**
|
|
* The sandbox mode this call executes under, required-but-nullable so every
|
|
* resolved spec states its policy. 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
|
|
}
|
|
```
|
|
|
|
`stdin` and `env` are trusted in-process plugin inputs and are not exposed by `dsh-tool-bash`. The local executor scrubs ambient credentials before merging explicit caller-supplied env. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
|
|
|
## Foreground runs: `BashRunResult`
|
|
|
|
The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success.
|
|
|
|
```ts type-equiv
|
|
interface BashRunResult {
|
|
/** Exit code; null when the process died from a signal. */
|
|
exitCode: number | null
|
|
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
|
|
signal: NodeJS.Signals | null
|
|
/** True when the executor's own timeout killed the command. */
|
|
timedOut: boolean
|
|
/** True when the caller's AbortSignal killed the command. */
|
|
aborted: boolean
|
|
/** The effective timeout applied to this run (after defaulting/capping). */
|
|
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
|
|
}
|
|
```
|
|
|
|
Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file:
|
|
|
|
```ts type-equiv
|
|
interface CollectedOutput {
|
|
/** Collected text — the TAIL of the stream when truncated. */
|
|
text: string
|
|
/** True when bytes were dropped from `text`. */
|
|
truncated: boolean
|
|
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
|
spillPath?: string
|
|
}
|
|
```
|
|
|
|
## File sandbox: `BashSandboxInfo`
|
|
|
|
A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `bash/sandbox-mode` override and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only.
|
|
|
|
A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel.
|
|
|
|
```ts type-equiv
|
|
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
|
|
}
|
|
```
|
|
|
|
One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend. A selected runner refusing its profile reaches the same fail-closed foreground error; a settled background task records `runnerFailed`. The model receives denial/runner facts in results, learns the effective mode only when a denial marker names it, and can request a one-shot strictly wider retry through `sandbox_permissions` plus `justification`; `ctx.approval` must grant that exact call before anything executes. The complete policy and switching design is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md).
|
|
|
|
## Background processes: `BashProcess`
|
|
|
|
`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
|
|
|
|
```ts type-equiv
|
|
interface BashProcess {
|
|
/** The command line this process runs. */
|
|
readonly command: string
|
|
/** Process lifecycle state (settled exactly once). */
|
|
status: BashProcessStatus
|
|
/** Exit code once finished (null = killed by signal / still running). */
|
|
exitCode: number | null
|
|
/** Terminating signal name, when signal-killed. */
|
|
signal: NodeJS.Signals | null
|
|
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
|
|
readonly done: Promise<void>
|
|
/** Sandbox facts, stamped once a confined process settles. */
|
|
sandbox?: BashSandboxInfo
|
|
/**
|
|
* Read output produced since the previous read (consuming — consecutive
|
|
* reads never re-deliver). Reads that lost data flag `lossy` and point at
|
|
* full-stream spill files when available.
|
|
*/
|
|
readOutput(): BashProcessRead
|
|
/**
|
|
* Kill the process group. Returns false when it had already finished
|
|
* (no-op); idempotent.
|
|
*/
|
|
kill(): boolean
|
|
}
|
|
```
|
|
|
|
`readOutput()` returns the incremental delta and spill recovery facts:
|
|
|
|
```ts type-equiv
|
|
interface BashProcessRead {
|
|
/** Output produced since the previous read (stderr in a marked section). */
|
|
delta: string
|
|
/** True when truncation dropped unread bytes the delta cannot include. */
|
|
lossy: boolean
|
|
/** Full stdout spill file, when stdout truncation occurred and a safe path is available. */
|
|
stdoutSpillPath?: string
|
|
/** Full stderr spill file, when stderr truncation occurred and a safe path is available. */
|
|
stderrSpillPath?: string
|
|
}
|
|
```
|
|
|
|
## The service
|
|
|
|
`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md).
|