Files
deepseek-harness/docs/core-data-structures/bash.md
T
Yichen Jiang 9956528495 Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# 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
2026-07-14 18:05:46 +08:00

12 KiB

Bash Executor

The bash execution seam is split across interface (dsh-bash, ctx.bash), implementations (dsh-bash-local and dsh-bash-sandbox), and consumer (dsh-tool-bash, the bash schema). Generic background-task ids, ownership, and controls live in tasks.md; this seam returns a task-free process handle.

Source: 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.

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
}
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.

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.

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:

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; 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.

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) 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.

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.

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:

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.