/** * Local Service provider for the bash capability seam over the subprocess * capability seam. Public commands run as `bash -c` in a managed process group spawned * through `ctx.subprocess`; subclasses may reuse the same mechanics with an * explicit argv. This executor owns command defaulting, deadlines and cause * classification, the model-friendly terminal environment, and the model-facing * stdout/stderr merge for background reads. Execution policy belongs in * `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /** * Model-friendly environment overrides: disable colors, pagers, and * interactive terminal features that would garble tool output (the same set * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — * merged first into the spawn's explicit env, so a trusted caller's own entry * still wins; the subprocess service applies its credential scrub independently. */ export const ENV_OVERRIDES = { NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', } as const /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ cwd?: string /** Default foreground timeout in milliseconds. */ timeoutMs?: number /** Upper bound for per-call timeout overrides. */ maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick /** Project a settled collect-mode reader into the final CollectedOutput shape. */ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { const read = reader.readFrom(0) return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, } } function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) } } /** * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and * process-group SIGTERM→SIGKILL escalation are the subprocess service's * mechanics; this executor supplies their configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { static inject = ['subprocess'] static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), graceMs: z.number().default(DEFAULT_GRACE_MS), }) /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig constructor(ctx: Context, config: Config) { super(ctx) // Schemastery fills these fields before construction; the type does not encode that step. this.config = config as ResolvedConfig assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) if (this.config.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) } } /** * Resolve a request into a fully-specified spec: fill `workdir` from * `config.cwd` (else `process.cwd()`), and `timeoutMs` from * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls * this before {@link run}/{@link start}, so those methods receive explicit * values and never re-default. */ resolve(request: BashExecRequest): BashExecSpec { const timeoutMs = clampTimeout( request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'bash-local: request.timeoutMs', ) const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes) return { command: request.command, workdir: request.workdir ?? this.config.cwd ?? process.cwd(), timeoutMs, stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, // Carry a sandbox policy through verbatim: this executor never // confines, so the field is inert here (the seam contract) — a // sandboxing subclass overrides resolve() to stamp its default instead. sandboxPolicy: request.sandboxPolicy, } } /** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. private spawnSpec( spec: BashExecSpec, argv: readonly string[], stdoutMaxBytes: number, signal: AbortSignal | undefined, ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { argv, cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', stdout: collect(stdoutMaxBytes), stderr: collect(this.config.maxOutputBytes), }, graceMs: this.config.graceMs, signal, // One explicit env map for the seam, layered so the trusted dshEnv // snapshot beats both the caller's env and the terminal overrides; the // subprocess service merges the whole map after its ambient scrub. env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, } } /** The collect-mode readers the executor itself requested (present by construction). */ private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { const { stdout, stderr } = handle.collected /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ if (stdout === undefined || stderr === undefined) { throw new Error('bash-local: subprocess implementation dropped a requested collect stream') } /* v8 ignore stop */ return { stdout, stderr } } async run(spec: BashExecSpec): Promise { return this.runArgv(spec, ['bash', '-c', spec.command]) } /** * Run an explicit argv with the foreground lifecycle, environment, output, * timeout, and cancellation semantics of this executor. Subclasses use this * after replacing the public command's shell argv at an execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. * @param argv - exact executable and arguments to hand to `ctx.subprocess`. * @returns the settled foreground result with collected output and cause facts. */ protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal)) const outcome = await handle.done const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs, stdout: finalOutput(collected.stdout), stderr: finalOutput(collected.stderr), } } start(spec: BashExecSpec): BashProcess { return this.startArgv(spec, ['bash', '-c', spec.command]) } /** * Start an explicit argv with the background lifecycle, environment, output, * cancellation, and process-tree ownership semantics of this executor. * Subclasses use this after replacing the public command's shell argv at an * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. * @param argv - exact executable and arguments to hand to `ctx.subprocess`. * @returns the live background handle; spawn rejection settles it as killed. */ protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing // to buffer; the note is delivered exactly once through the read path. let spawnFailureNote: string | undefined const consumeSpawnFailure = (): string => { const note = spawnFailureNote ?? '' spawnFailureNote = undefined return note } let stdoutOffset = 0 let stderrOffset = 0 const proc: BashProcess = { status: 'running', exitCode: null, signal: null, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed' } proc.exitCode = outcome.exitCode proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset // A failed spawn never produced process output, so the note and real // stderr text are mutually exclusive. const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' const delta = out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {}, ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {}, } }, kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' running.terminate() return true }, } return proc } /** * Settlement hook for subclasses that attach execution facts to a process. * Called after exit facts or spawn-failure output are stamped and before * {@link BashProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the subprocess promise rejected before a process started. * @param _spawnError - the original spawn rejection reason, which may itself be undefined. */ protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } export default LocalBashExecutor