index.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * Local-subprocess implementation of the bash executor seam. Each command runs
  3. * as `bash -c` in its own process group; disposal kills and joins live groups.
  4. * Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
  5. * @module @deepseek-ai/dsh-bash-local
  6. */
  7. import { Context } from 'cordis'
  8. import z from 'schemastery'
  9. import { BashExecutor } from '@deepseek-ai/dsh-bash'
  10. import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
  11. import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  12. import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
  13. import type { RunInternals, RunningBash } from './run.ts'
  14. /** Plugin config (all optional — `static Config` supplies the defaults). */
  15. export interface Config {
  16. /** Default working directory for commands (default: process.cwd()). */
  17. cwd?: string
  18. /** Default foreground timeout in milliseconds. */
  19. timeoutMs?: number
  20. /** Upper bound for per-call timeout overrides. */
  21. maxTimeoutMs?: number
  22. /** Per-stream in-memory output cap; overflow spills to a temp file. */
  23. maxOutputBytes?: number
  24. /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
  25. maxSpillBytes?: number
  26. /** Grace period for kill escalation and for inherited pipes after shell exit. */
  27. graceMs?: number
  28. }
  29. /** The shape after schemastery applied the defaults (cwd has none). */
  30. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  31. function assertPositiveFinite(name: string, value: number): void {
  32. if (!Number.isFinite(value) || value <= 0) {
  33. throw new Error(`bash-local: ${name} must be a positive finite number`)
  34. }
  35. }
  36. /**
  37. * Local bash executor with bounded output, spill files, and process-group
  38. * `SIGTERM` to `SIGKILL` escalation.
  39. */
  40. export class LocalBashExecutor extends BashExecutor {
  41. static Config: z<Config> = z.object({
  42. cwd: z.string(),
  43. timeoutMs: z.number().default(120_000),
  44. maxTimeoutMs: z.number().default(600_000),
  45. maxOutputBytes: z.number().default(64_000),
  46. maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
  47. graceMs: z.number().default(DEFAULT_GRACE_MS),
  48. })
  49. /** Live processes retained only so disposal can kill and join them. */
  50. private live = new Map<BashProcess, RunningBash>()
  51. /** Test seam: spill knobs forwarded to runBash. */
  52. internals: RunInternals = {}
  53. /** Validated config (schemastery applied the defaults before construction). */
  54. readonly config: ResolvedConfig
  55. constructor(ctx: Context, config: Config) {
  56. super(ctx)
  57. // Schemastery fills these fields before construction; the type does not encode that step.
  58. this.config = config as ResolvedConfig
  59. assertPositiveFinite('timeoutMs', this.config.timeoutMs)
  60. assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
  61. assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
  62. assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
  63. assertPositiveFinite('graceMs', this.config.graceMs)
  64. ctx.effect(() => async () => {
  65. // Await closure so even a TERM-trapping child cannot outlive the fiber.
  66. const pending: Promise<void>[] = []
  67. for (const [proc, running] of this.live) {
  68. proc.status = 'killed'
  69. running.kill()
  70. pending.push(proc.done)
  71. }
  72. this.live.clear()
  73. await Promise.all(pending)
  74. }, 'local bash teardown')
  75. }
  76. /**
  77. * Resolve a request into a fully-specified spec: fill `workdir` from
  78. * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
  79. * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
  80. * this before {@link run}/{@link start}, so those methods receive explicit
  81. * values and never re-default.
  82. */
  83. resolve(request: BashExecRequest): BashExecSpec {
  84. const timeoutMs = clampTimeout(
  85. request.timeoutMs,
  86. this.config.timeoutMs,
  87. this.config.maxTimeoutMs,
  88. 'bash-local: request.timeoutMs',
  89. )
  90. const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
  91. assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
  92. return {
  93. command: request.command,
  94. workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
  95. timeoutMs,
  96. stdoutMaxBytes,
  97. ...request.signal ? { signal: request.signal } : {},
  98. // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
  99. // no config default. run.ts owns the scrub and merge order.
  100. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  101. ...request.env !== undefined ? { env: request.env } : {},
  102. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  103. // Carry a sandbox policy through verbatim: this executor never
  104. // confines, so the field is inert here (the seam contract) — a
  105. // sandboxing subclass overrides resolve() to stamp its default instead.
  106. sandboxPolicy: request.sandboxPolicy,
  107. }
  108. }
  109. async run(spec: BashExecSpec): Promise<BashRunResult> {
  110. // One deadline combines timeout and upstream cancellation; disposal clears its timer.
  111. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
  112. const outcome = await runBash({
  113. command: spec.command,
  114. cwd: spec.workdir,
  115. stdoutMaxBytes: spec.stdoutMaxBytes,
  116. stderrMaxBytes: this.config.maxOutputBytes,
  117. maxSpillBytes: this.config.maxSpillBytes,
  118. graceMs: this.config.graceMs,
  119. signal: d.signal,
  120. stdin: spec.stdin,
  121. env: spec.env,
  122. dshEnv: spec.dshEnv,
  123. }, this.internals).done
  124. // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
  125. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
  126. const aborted = d.signal.aborted && !timedOut
  127. return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
  128. }
  129. start(spec: BashExecSpec): BashProcess {
  130. // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
  131. const running = runBash({
  132. command: spec.command,
  133. cwd: spec.workdir,
  134. stdoutMaxBytes: this.config.maxOutputBytes,
  135. stderrMaxBytes: this.config.maxOutputBytes,
  136. maxSpillBytes: this.config.maxSpillBytes,
  137. graceMs: this.config.graceMs,
  138. signal: spec.signal,
  139. stdin: spec.stdin,
  140. env: spec.env,
  141. dshEnv: spec.dshEnv,
  142. }, this.internals)
  143. let stdoutOffset = 0
  144. let stderrOffset = 0
  145. const proc: BashProcess = {
  146. status: 'running',
  147. exitCode: null,
  148. signal: null,
  149. done: running.done.then((outcome) => {
  150. // Any signal termination is killed, including a command signaling itself.
  151. if (proc.status === 'running') {
  152. proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
  153. }
  154. proc.exitCode = outcome.exitCode
  155. proc.signal = outcome.signal
  156. this.onProcessDone(proc, running.stderr.readFrom(0).text)
  157. this.live.delete(proc)
  158. }, (error: unknown) => {
  159. // Background spawn failures settle as killed and surface through the read path.
  160. proc.status = 'killed'
  161. running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
  162. this.onProcessDone(proc, running.stderr.readFrom(0).text)
  163. this.live.delete(proc)
  164. }),
  165. readOutput: (): BashProcessRead => {
  166. const out = running.stdout.readFrom(stdoutOffset)
  167. const err = running.stderr.readFrom(stderrOffset)
  168. stdoutOffset = out.nextOffset
  169. stderrOffset = err.nextOffset
  170. // Single newline between sections: stdout chunks usually end with one
  171. // already; add it only when missing.
  172. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
  173. const delta = out.text
  174. + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
  175. return {
  176. delta,
  177. lossy: out.lossy || err.lossy,
  178. ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
  179. ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
  180. }
  181. },
  182. kill: (): boolean => {
  183. if (proc.status !== 'running') return false
  184. proc.status = 'killed'
  185. running.kill()
  186. return true
  187. },
  188. }
  189. this.live.set(proc, running)
  190. return proc
  191. }
  192. /**
  193. * Settlement hook for subclasses that attach execution facts to a process.
  194. * Called after exit facts or spawn-failure output are stamped and before
  195. * {@link BashProcess.done} resolves. The base implementation is intentionally
  196. * empty.
  197. * @param _proc - the settled process handle.
  198. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
  199. */
  200. protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
  201. }
  202. export default LocalBashExecutor