Review direction (tianyicui, PR #660): in a stacked PR, change all other process-running places to use the new service. - lsp-local: LspConnection spawns through ctx.subprocess (piped protocol streams + a no-spill collected stderr tail); its private process-tree helpers (POSIX group signalling, Windows taskkill, liveness polling) are deleted in favor of the seam's handle verbs, and its buildChildEnv now rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*). The plugin injects 'subprocess'; compositions/tests mount dsh-subprocess-local. - subagent-acp: the ACP child spawns through the seam (piped ndjson streams, inherited stderr); spawn failure surfaces through done-rejection into the same startup race; disposal is handle.dispose with the plugin's configured graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub are the seam's, and the isolated-config-dir helper had no consumer. - mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one scrub definition (their spawns stay put by ownership: the MCP SDK and node-pty own those calls; the SDK wizard runs outside any composition). - Coverage: per-file 100% over every touched src file, with each v8 ignore carrying a platform or contract reason; new suites cover stdio dispositions, the dispose ladder tiers, injected-win32 tree semantics, waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal. - Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note updated in place, subprocess.md rewritten for the reshaped vocabulary (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill added to knip ignoreBinaries.
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
|
* Local implementation of the bash executor seam over the subprocess
|
|
* seam. Each command runs as `bash -c` in a managed process group spawned
|
|
* through `ctx.subprocess`; 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, 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 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 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'>
|
|
|
|
/** 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)
|
|
}
|
|
|
|
/**
|
|
* 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 onto a fully-specified subprocess spawn. */
|
|
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
|
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
|
const collect = (maxBytes: number): SubprocessCollect =>
|
|
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
|
return {
|
|
argv: ['bash', '-c', spec.command],
|
|
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,
|
|
env: { ...ENV_OVERRIDES, ...spec.env },
|
|
dshEnv: 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> {
|
|
// 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, 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 {
|
|
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
|
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, 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)
|
|
}, (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 = 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.
|
|
*/
|
|
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
|
}
|
|
|
|
export default LocalBashExecutor
|