| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- /**
- * Local implementation of the bash executor seam over the subprocess
- * 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 'cordis'
- import z from '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<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
- /** 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<Config> = 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<BashRunResult> {
- 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<BashRunResult> {
- // 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
|