Files
deepseek-harness/packages/shell/bash-local/src/index.ts
T
Yichen Jiang bb20149360 refactor(shell): register foreground commands as jobs at start and drop the promotion protocol
With a job registry composed, tool-bash and tool-pwsh now register every
command with ctx.jobs as it starts and wait on the job with the registry's
own bounded wait: a command that finishes in time returns the ordinary
foreground result and its record is removed, one that outlives the timeout
returns the same job id it already had, and a kill from outside the call is
reported as `[stopped: <reason>]`. Nothing changes hands at the deadline, so
the shell seam loses `onExpiry: 'offer'`, `ShellExecution.promotion`, and
`ShellPromotionOffer`, and both local executors drop their offer arm.

The registry stays optional: the tools register a foreground-only definition
while no registry is composed and swap to the job-backed one inside a
`ctx.inject(['jobs'])` fork, so a composition without jobs keeps a plain bash
with no job tools in the model's schema. A refused registration at the start
(admission, no controller) runs under the executor's deadline kill, and a wait
that expires before the process spawned ends as the preparation timeout.

JobRegistry gains `remove(id, caller?)` and the `settled` event reports
`awaited`, so dsh-tool-jobs no longer keeps its own wait ledger; it withholds
notices for awaited settlements and for kills the model requested. The tool
catalog harvest composes the registry so the shipped schema stays documented,
and the two compaction fixtures move their context window from 10000 to 10300
tokens because the branch's larger tool schemas pushed their condensed history
over the old trigger, which requested a fourth scripted model call.
2026-09-21 15:23:08 +08:00

432 lines
19 KiB
TypeScript

/**
* 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, ShellExecution, 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 execute}, so it receives explicit values and never
* re-defaults.
*/
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,
onExpiry: request.onExpiry ?? 'kill',
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 execute(spec: ShellExecSpec): Promise<ShellExecution> {
return this.executeArgv(spec, ['bash', '-c', spec.command])
}
/**
* Execute an explicit argv with the lifecycle, environment, output,
* deadline, 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 argvOrPrepare - exact argv, or preparation using the execution cancellation signal.
* @param onStarted - installs provider facts synchronously before the handle can settle.
* @returns the live execution handle; spawn rejection settles the handle as
* killed while `result()` carries the same failure as its rejection.
*/
protected async executeArgv(
spec: ShellExecSpec,
argvOrPrepare: readonly string[] | ((signal: AbortSignal) => Promise<readonly string[]>),
onStarted?: (process: ShellExecution) => void,
): Promise<ShellExecution> {
// Deadline wiring by expiry policy. Each arm supplies the spawn signal,
// the result projection's first-cause classification, and the disarm the
// settlement continuation runs. `ShellExpiryPolicy` has exactly these two
// members, so the `else` arm is `'none'`.
let spawnSignal: AbortSignal | undefined
let classify: () => { timedOut: boolean; aborted: boolean }
let disarm = (): void => {}
if (spec.onExpiry === 'kill') {
// One fused deadline combines timeout and upstream cancellation; only
// this executor's timeout reason counts as timedOut, outer deadlines as aborts.
const d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
spawnSignal = d.signal
classify = () => {
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
return { timedOut, aborted: d.signal.aborted && !timedOut }
}
disarm = () => { d[Symbol.dispose]() }
} else {
// No deadline: callers stop the process through kill() or spec.signal.
spawnSignal = spec.signal
classify = () => ({ timedOut: false, aborted: spec.signal?.aborted === true })
}
let argv: readonly string[] = []
let preparationTimedOut = false
if (typeof argvOrPrepare === 'function') {
const signal = spawnSignal ?? new AbortController().signal
const cancelled = Promise.withResolvers<never>()
const abort = (): void => { cancelled.reject(signal.reason) }
signal.addEventListener('abort', abort, { once: true })
try {
argv = await Promise.race([
Promise.resolve().then(() => { signal.throwIfAborted(); return argvOrPrepare(signal) }),
cancelled.promise,
])
signal.throwIfAborted()
} catch (error) {
if (!classify().timedOut) {
disarm()
throw error
}
preparationTimedOut = true
} finally { signal.removeEventListener('abort', abort) }
} else { argv = argvOrPrepare }
// A synchronous spawn throw (pre-aborted signal, alternative subprocess
// implementations) is contained into the same settled-killed shape as an
// asynchronous spawn rejection, so execute() itself never throws for a
// spawn problem and result() carries the failure uniformly.
let running: SubprocessHandle | undefined
let syncSpawnError: { error: unknown } | undefined
try {
if (!preparationTimedOut) {
running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, spawnSignal))
}
} catch (error) {
syncSpawnError = { error }
}
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}
const collected = running !== undefined
? LocalBashExecutor.collected(running)
: { stdout: emptyReader, stderr: emptyReader }
const spawnThrow = (): unknown => (syncSpawnError as { error: unknown }).error
const spawned = preparationTimedOut
? Promise.resolve({ exitCode: null, signal: null })
: running !== undefined ? running.done
// The original throw is preserved for callers even when it was not an Error.
// eslint-disable-next-line prefer-promise-reject-errors
: Promise.reject(spawnThrow())
// A provider rejection produces no process output, so the subprocess
// service has nothing to buffer: once the provider rejected, its
// stage-neutral note is the whole stderr stream for every reader. The
// observed reader serves it at offset 0, the consuming read folds it in
// exactly once, and the retained error is the result() projection's
// rejection.
let providerFailure: { error: unknown; note: string } | undefined
let providerFailureReported = false
const consumeProviderFailure = (): string => {
if (providerFailure === undefined || providerFailureReported) return ''
providerFailureReported = true
return providerFailure.note
}
const observedStderr: SubprocessOutputReader = {
readFrom: (fromByte) => {
if (providerFailure === undefined) return collected.stderr.readFrom(fromByte)
const note = Buffer.from(providerFailure.note, 'utf8')
return { text: note.subarray(Math.min(fromByte, note.length)).toString('utf8'), nextOffset: note.length, lossy: false }
},
}
let stdoutOffset = 0
let stderrOffset = 0
let resultPromise: Promise<ShellRunResult> | undefined
const proc: ShellExecution = {
status: 'running',
exitCode: null,
signal: null,
observed: { stdout: collected.stdout, stderr: observedStderr },
done: spawned.then((outcome) => {
// Any signal termination is killed, including a command signaling itself.
if (proc.status === 'running') {
proc.status = spawnSignal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
disarm()
}, (error: unknown) => {
// A live handle whose rejection follows this execution's own
// termination — kill() or the spawn signal's abort — reports its
// terminal outcome: a provider that terminated the range before the
// target started has no exit to report and rejects with the
// cancellation reason instead. The result projection classifies it
// from the deadline wiring; nothing is a provider failure. A
// synchronous spawn throw never produced a handle and stays a failure.
if (running !== undefined && (proc.status === 'killed' || spawnSignal?.aborted === true)) {
proc.status = 'killed'
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
disarm()
return
}
// Provider failures settle the handle as killed and surface on stderr for every reader.
proc.status = 'killed'
let detail = 'unprintable provider failure'
try {
detail = String(error)
} catch {
// Provider-owned rejection values cannot make ShellProcess.done reject.
}
providerFailure = { error, note: `subprocess failed before reporting an outcome: ${detail}` }
this.onProcessDone(proc, providerFailure.note, true, error)
disarm()
}),
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
},
result: (): Promise<ShellRunResult> => {
resultPromise ??= proc.done.then(() => {
// Infrastructure-failure parity with the historical foreground path:
// a spawn that never produced a process rejects the projection.
if (providerFailure !== undefined) throw providerFailure.error
return {
exitCode: proc.exitCode,
signal: proc.signal,
...classify(),
timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout),
stderr: finalOutput(collected.stderr),
}
})
return resultPromise
},
}
if (!preparationTimedOut) onStarted?.(proc)
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