index.ts 8.4 KB

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