| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- /**
- * Local Service Provider for the bash capability seam over the subprocess
- * capability seam. Public commands run as `bash -c` in a provider-managed range
- * 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 { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell'
- import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
- import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
- import type {} from '@deepseek-ai/dsh-settings'
- 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`)
- }
- }
- /**
- * Reject a resolved section this executor could not run with. The schema
- * expresses neither "positive and finite" nor the timer bound `graceMs` has to
- * fit, so a stored value is refused where it is written instead of failing at
- * the next command.
- * @param config - the resolved section, schema-valid by construction.
- * @throws Error naming the field that cannot be used.
- */
- export function assertServiceableBashConfig(config: Config): void {
- const resolved = config as ResolvedConfig
- assertPositiveFinite('timeoutMs', resolved.timeoutMs)
- assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
- assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
- assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
- assertPositiveFinite('graceMs', resolved.graceMs)
- if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
- throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
- }
- }
- /**
- * Local bash executor over `ctx.subprocess`. Bounded output, spill files,
- * managed-range SIGTERM→SIGKILL escalation, and quiescence 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 ShellExecutor {
- 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),
- })
- /** The currently authoritative config: the settings section, or the composition entry. */
- private source: () => ResolvedConfig
- /** Validated config (schemastery applied the defaults before construction). */
- get config(): ResolvedConfig {
- return this.source()
- }
- constructor(ctx: Context, config: Config) {
- super(ctx)
- // Schemastery fills these fields before construction; the type does not encode that step.
- const entry = config as ResolvedConfig
- assertServiceableBashConfig(entry)
- this.source = () => entry
- ctx.inject(['settings'], (settingsCtx) => {
- settingsCtx.settings.installSection(ctx, SHELL_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
- validate: assertServiceableBashConfig,
- setSource: (current) => {
- this.source = current as () => ResolvedConfig
- },
- // Every field is read through the getter at each command, so nothing
- // derived from the source needs rebuilding when the document changes.
- onChange: () => {},
- })
- })
- }
- /**
- * 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: ShellExecRequest): ShellExecSpec {
- 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: ShellExecSpec,
- 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: ShellExecSpec): Promise<ShellRunResult> {
- 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: ShellExecSpec, argv: readonly string[]): Promise<ShellRunResult> {
- // 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: ShellExecSpec): ShellProcess {
- return this.startArgv(spec, ['bash', '-c', spec.command])
- }
- /**
- * Start an explicit argv with the background lifecycle, environment, output,
- * cancellation, and managed-range 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; provider rejection settles it as killed.
- */
- protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
- // 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 provider rejection has no direct outcome to display; its stage is not
- // public, so a neutral note is delivered once through the read path.
- let providerFailureNote: string | undefined
- const consumeProviderFailure = (): string => {
- const note = providerFailureNote ?? ''
- providerFailureNote = undefined
- return note
- }
- let stdoutOffset = 0
- let stderrOffset = 0
- const proc: ShellProcess = {
- 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 provider failures settle as killed and surface through the read path.
- proc.status = 'killed'
- let detail = 'unprintable provider failure'
- try {
- detail = String(error)
- } catch {
- // Provider-owned rejection values cannot make ShellProcess.done reject.
- }
- providerFailureNote = `subprocess failed before reporting an outcome: ${detail}`
- this.onProcessDone(proc, providerFailureNote, true, error)
- }),
- readOutput: (): ShellProcessRead => {
- const out = collected.stdout.readFrom(stdoutOffset)
- const err = collected.stderr.readFrom(stderrOffset)
- stdoutOffset = out.nextOffset
- stderrOffset = err.nextOffset
- const providerFailure = consumeProviderFailure()
- const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : ''
- const errText = err.text
- + (providerFailure.length > 0 ? `${failureSeparator}${providerFailure}` : '')
- // 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 provider-failure output are stamped and before
- * {@link ShellProcess.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 _providerRejected - whether the subprocess promise rejected without a direct outcome.
- * @param _providerError - the provider rejection reason, which may itself be undefined.
- */
- protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {}
- }
- export default LocalBashExecutor
|