index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * Local implementation of the bash executor seam over the subprocess
  3. * seam. Each command runs as `bash -c` in a managed process group spawned
  4. * through `ctx.subprocess`; this executor owns command defaulting, deadlines
  5. * and cause classification, the model-friendly terminal environment, and the
  6. * model-facing stdout/stderr merge for background reads. Execution policy
  7. * belongs in `tools/pre-execute` or a sandboxing executor.
  8. * @module @deepseek-ai/dsh-bash-local
  9. */
  10. import { Context } from 'cordis'
  11. import z from 'schemastery'
  12. import { BashExecutor } from '@deepseek-ai/dsh-bash'
  13. import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
  14. import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  15. import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  16. /**
  17. * Model-friendly environment overrides: disable colors, pagers, and
  18. * interactive terminal features that would garble tool output (the same set
  19. * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
  20. * merged first into the spawn's explicit env, so a trusted caller's own entry
  21. * still wins; the subprocess service applies its credential scrub independently.
  22. */
  23. export const ENV_OVERRIDES = {
  24. NO_COLOR: '1',
  25. TERM: 'dumb',
  26. PAGER: 'cat',
  27. GIT_PAGER: 'cat',
  28. } as const
  29. /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
  30. const DEFAULT_GRACE_MS = 3_000
  31. /** Default per-stream spill cap (the `maxSpillBytes` config). */
  32. const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
  33. /** Plugin config (all optional — `static Config` supplies the defaults). */
  34. export interface Config {
  35. /** Default working directory for commands (default: process.cwd()). */
  36. cwd?: string
  37. /** Default foreground timeout in milliseconds. */
  38. timeoutMs?: number
  39. /** Upper bound for per-call timeout overrides. */
  40. maxTimeoutMs?: number
  41. /** Per-stream in-memory output cap; overflow spills to a temp file. */
  42. maxOutputBytes?: number
  43. /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
  44. maxSpillBytes?: number
  45. /** Grace period for kill escalation and for inherited pipes after shell exit. */
  46. graceMs?: number
  47. }
  48. /** The shape after schemastery applied the defaults (cwd has none). */
  49. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  50. /** Project a settled collect-mode reader into the final CollectedOutput shape. */
  51. function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
  52. const read = reader.readFrom(0)
  53. return {
  54. text: read.text,
  55. truncated: read.lossy,
  56. ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
  57. }
  58. }
  59. function assertPositiveFinite(name: string, value: number): void {
  60. if (!Number.isFinite(value) || value <= 0) {
  61. throw new Error(`bash-local: ${name} must be a positive finite number`)
  62. }
  63. }
  64. /**
  65. * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
  66. * process-group SIGTERM→SIGKILL escalation are the subprocess service's
  67. * mechanics; this executor supplies their configured budgets per spawn, so a
  68. * still-running background process stays managed (killed and joined at
  69. * composition teardown) even across an executor reload.
  70. */
  71. export class LocalBashExecutor extends BashExecutor {
  72. static inject = ['subprocess']
  73. static Config: z<Config> = z.object({
  74. cwd: z.string(),
  75. timeoutMs: z.number().default(120_000),
  76. maxTimeoutMs: z.number().default(600_000),
  77. maxOutputBytes: z.number().default(64_000),
  78. maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
  79. graceMs: z.number().default(DEFAULT_GRACE_MS),
  80. })
  81. /** Validated config (schemastery applied the defaults before construction). */
  82. readonly config: ResolvedConfig
  83. constructor(ctx: Context, config: Config) {
  84. super(ctx)
  85. // Schemastery fills these fields before construction; the type does not encode that step.
  86. this.config = config as ResolvedConfig
  87. assertPositiveFinite('timeoutMs', this.config.timeoutMs)
  88. assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
  89. assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
  90. assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
  91. assertPositiveFinite('graceMs', this.config.graceMs)
  92. }
  93. /**
  94. * Resolve a request into a fully-specified spec: fill `workdir` from
  95. * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
  96. * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
  97. * this before {@link run}/{@link start}, so those methods receive explicit
  98. * values and never re-default.
  99. */
  100. resolve(request: BashExecRequest): BashExecSpec {
  101. const timeoutMs = clampTimeout(
  102. request.timeoutMs,
  103. this.config.timeoutMs,
  104. this.config.maxTimeoutMs,
  105. 'bash-local: request.timeoutMs',
  106. )
  107. const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
  108. assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
  109. return {
  110. command: request.command,
  111. workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
  112. timeoutMs,
  113. stdoutMaxBytes,
  114. ...request.signal ? { signal: request.signal } : {},
  115. // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
  116. // no config default. The subprocess service owns the scrub and merge order.
  117. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  118. ...request.env !== undefined ? { env: request.env } : {},
  119. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  120. // Carry a sandbox policy through verbatim: this executor never
  121. // confines, so the field is inert here (the seam contract) — a
  122. // sandboxing subclass overrides resolve() to stamp its default instead.
  123. sandboxPolicy: request.sandboxPolicy,
  124. }
  125. }
  126. /** Map one resolved bash spec onto a fully-specified subprocess spawn. */
  127. // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
  128. private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
  129. const collect = (maxBytes: number): SubprocessCollect =>
  130. ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
  131. return {
  132. argv: ['bash', '-c', spec.command],
  133. cwd: spec.workdir,
  134. stdio: {
  135. stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
  136. stdout: collect(stdoutMaxBytes),
  137. stderr: collect(this.config.maxOutputBytes),
  138. },
  139. graceMs: this.config.graceMs,
  140. signal,
  141. // One explicit env map for the seam, layered so the trusted dshEnv
  142. // snapshot beats both the caller's env and the terminal overrides; the
  143. // subprocess service merges the whole map after its ambient scrub.
  144. env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
  145. }
  146. }
  147. /** The collect-mode readers the executor itself requested (present by construction). */
  148. private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
  149. const { stdout, stderr } = handle.collected
  150. /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
  151. if (stdout === undefined || stderr === undefined) {
  152. throw new Error('bash-local: subprocess implementation dropped a requested collect stream')
  153. }
  154. /* v8 ignore stop */
  155. return { stdout, stderr }
  156. }
  157. async run(spec: BashExecSpec): Promise<BashRunResult> {
  158. // One deadline combines timeout and upstream cancellation; disposal clears its timer.
  159. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
  160. const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
  161. const outcome = await handle.done
  162. const collected = LocalBashExecutor.collected(handle)
  163. // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
  164. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
  165. const aborted = d.signal.aborted && !timedOut
  166. return {
  167. ...outcome,
  168. timedOut,
  169. aborted,
  170. timeoutMs: spec.timeoutMs,
  171. stdout: finalOutput(collected.stdout),
  172. stderr: finalOutput(collected.stderr),
  173. }
  174. }
  175. start(spec: BashExecSpec): BashProcess {
  176. // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
  177. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
  178. const collected = LocalBashExecutor.collected(running)
  179. // A spawn failure produces no process output, so the subprocess service has nothing
  180. // to buffer; the note is delivered exactly once through the read path.
  181. let spawnFailureNote: string | undefined
  182. const consumeSpawnFailure = (): string => {
  183. const note = spawnFailureNote ?? ''
  184. spawnFailureNote = undefined
  185. return note
  186. }
  187. let stdoutOffset = 0
  188. let stderrOffset = 0
  189. const proc: BashProcess = {
  190. status: 'running',
  191. exitCode: null,
  192. signal: null,
  193. done: running.done.then((outcome) => {
  194. // Any signal termination is killed, including a command signaling itself.
  195. if (proc.status === 'running') {
  196. proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
  197. }
  198. proc.exitCode = outcome.exitCode
  199. proc.signal = outcome.signal
  200. this.onProcessDone(proc, collected.stderr.readFrom(0).text)
  201. }, (error: unknown) => {
  202. // Background spawn failures settle as killed and surface through the read path.
  203. proc.status = 'killed'
  204. spawnFailureNote = `spawn failed: ${String(error)}`
  205. this.onProcessDone(proc, spawnFailureNote)
  206. }),
  207. readOutput: (): BashProcessRead => {
  208. const out = collected.stdout.readFrom(stdoutOffset)
  209. const err = collected.stderr.readFrom(stderrOffset)
  210. stdoutOffset = out.nextOffset
  211. stderrOffset = err.nextOffset
  212. // A failed spawn never produced process output, so the note and real
  213. // stderr text are mutually exclusive.
  214. const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
  215. // Single newline between sections: stdout chunks usually end with one
  216. // already; add it only when missing.
  217. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
  218. const delta = out.text
  219. + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
  220. return {
  221. delta,
  222. lossy: out.lossy || err.lossy,
  223. ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
  224. ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
  225. }
  226. },
  227. kill: (): boolean => {
  228. if (proc.status !== 'running') return false
  229. proc.status = 'killed'
  230. running.terminate()
  231. return true
  232. },
  233. }
  234. return proc
  235. }
  236. /**
  237. * Settlement hook for subclasses that attach execution facts to a process.
  238. * Called after exit facts or spawn-failure output are stamped and before
  239. * {@link BashProcess.done} resolves. The base implementation is intentionally
  240. * empty.
  241. * @param _proc - the settled process handle.
  242. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
  243. */
  244. protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
  245. }
  246. export default LocalBashExecutor