0d6bfd8856
New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes — abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec — plus the shared DSH_* managed-environment and CollectedOutput vocabulary; @deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing (detached groups, tail-keep spill-backed output, credential scrub, kill escalation, kill-and-join disposal) with no config of its own. dsh-bash-local becomes a consumer: it keeps command defaulting, the fused deadline timedOut/aborted classification, the model-friendly terminal env (now merged through the ordinary env channel), and the [stderr]-marked background read merge, and spawns through ctx.processes. Background-process lifetime moves to the manager, so an executor reload no longer kills live background work; a background spawn failure is injected once into the read path instead of being buffered as fake stderr. dsh-bash re-exports the moved vocabulary so bash consumers keep one import root; dsh-bash-sandbox only redeclares the inherited inject. Every composition loading a bash executor now loads dsh-process-local (CLI, examples, python bundled runtime, create-sdk bash feature, inline test configs).
233 lines
9.8 KiB
TypeScript
233 lines
9.8 KiB
TypeScript
/**
|
|
* Local implementation of the bash executor seam over the process-manager
|
|
* seam. Each command runs as `bash -c` in a managed process group spawned
|
|
* through `ctx.processes`; 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 } from '@deepseek-ai/dsh-bash'
|
|
import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
|
import { clampTimeout, deadline, 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 into the ordinary env channel, so a trusted caller's own entry still
|
|
* wins; the process manager 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 for inherited pipes after shell exit. */
|
|
graceMs?: number
|
|
}
|
|
|
|
/** The shape after schemastery applied the defaults (cwd has none). */
|
|
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
|
|
|
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.processes`. Bounded output, spill files, and
|
|
* process-group SIGTERM→SIGKILL escalation are the process manager'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 = ['processes']
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* 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 process manager 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 onto a fully-specified process spawn. */
|
|
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
|
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec {
|
|
return {
|
|
argv: ['bash', '-c', spec.command],
|
|
cwd: spec.workdir,
|
|
stdoutMaxBytes,
|
|
stderrMaxBytes: this.config.maxOutputBytes,
|
|
maxSpillBytes: this.config.maxSpillBytes,
|
|
graceMs: this.config.graceMs,
|
|
signal,
|
|
stdin: spec.stdin,
|
|
env: { ...ENV_OVERRIDES, ...spec.env },
|
|
dshEnv: spec.dshEnv,
|
|
}
|
|
}
|
|
|
|
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
|
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
|
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
|
const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
|
|
// 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 }
|
|
}
|
|
|
|
start(spec: BashExecSpec): BashProcess {
|
|
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
|
const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
|
|
|
// A spawn failure produces no process output, so the manager 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, running.stderr.readFrom(0).text)
|
|
}, (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)
|
|
}),
|
|
readOutput: (): BashProcessRead => {
|
|
const out = running.stdout.readFrom(stdoutOffset)
|
|
const err = running.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.kill()
|
|
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.
|
|
*/
|
|
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
|
}
|
|
|
|
export default LocalBashExecutor
|