Files
deepseek-harness/docs/core-data-structures/bash.md
T
Yichen Jiang 184e164091 feat(tasks): background task runtime, generic task_* control tools, bash/subagent producers
One shared ctx.tasks registry (branded <kind>-N ids, owner-fenced
read/kill/wait/list, attachSurface misconfiguration fence, reported-flag
notice dedup, atomic register) + dsh-tool-tasks (task_output/task_list/
task_kill, completion-notice injection, background prompt habit).
Producers opt in via their own enableRunInBackground config: bash
(stream kind; seam slimmed to resolve/run/start returning a BashProcess
handle, bash_output/bash_kill deleted) and subagent (final-output kind;
done settles after run.dispose()). Owner disposal drains tasks through
the new awaited ctx.agents.onCleanup seam in the loop's disposal chain.
Both RFCs moved to implemented/; docs, catalogs, snapshots re-pinned.
2026-07-09 21:22:54 +08:00

9.4 KiB

Bash Executor

The bash execution seam — the canonical capability seam example, split across three packages: interface (dsh-bash, ctx.bash), implementation (dsh-bash-local, local subprocesses), and consumer (dsh-tool-bash, the bash tool schema). Bash is one optional capability, not part of the agent-loop spine — so its vocabulary lives here, not in core.md. A sandboxed, containerized, or remote backend is a sibling package implementing the same interface.

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
}
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}. OPTIONAL on the resolved spec:
   * it has no config default, so a missing one means "no stdin" — the safe,
   * ordinary case — not a silent footgun, so it stays a plain optional rather
   * than required-but-nullable (see the request field).
   */
  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 seam is deliberately task-free: no task ids, no owner tokens, no polling protocol. Background-task semantics (ids, cross-session isolation, collect/stop tools, completion notices) live in the generic ctx.tasks runtime (dsh-tasks); the tool layer adapts a BashProcess handle into a task registration, so a sandboxed or remote executor inherits no session or registry dependency.

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 — its request is built from command/workdir/timeoutMs/signal only — because a model already has equivalent power through shell syntax (FOO=bar cmd, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in dsh-bash-local is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model env/stdin — to catch a future ...args spread, not to defend a trust wall. env is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. 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
}

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
}

Background processes: BashProcess

A long-running command started with start() returns a BashProcess handle — the only access path (no executor-level id lookup). BashProcessStatus is 'running' | 'completed' | 'killed'; done resolves when the underlying process closes and never rejects (a spawn failure settles as killed with the error readable on stderr). Reads stay valid after exit: the remaining buffered output is still consumable through the handle.

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>
  /**
   * 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 an incremental BashProcessRead — the output produced since the previous read, with a lossy flag when truncation dropped unread bytes:

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 (ctx.bash, abstract — defined in packages/bash/bash/src/index.ts) mirrors the LlmService/LlmAdapter split and is exactly three methods: resolve (request → spec), run (foreground), start (background, returning the BashProcess handle). Spawned commands get a scrubbed env (dropping *KEY*/*SECRET*/*TOKEN*) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is dsh-bash-local; the model-facing bash schema that calls it is in dsh-tool-bash (background runs register with ctx.tasks and are collected via the generic task_output/task_kill), presenting as terminals via the tool-presentation vocabulary.