index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /**
  2. * Local-subprocess implementation of the bash seam. Each call runs in its own
  3. * process group, background tasks are tracked, and disposal kills and awaits
  4. * them. Execution policy belongs in `tools/pre-execute` or a sandboxing
  5. * executor, not this local process layer.
  6. * @module @deepseek-ai/dsh-bash-local
  7. */
  8. import { Context } from 'cordis'
  9. import z from 'schemastery'
  10. import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
  11. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
  12. import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
  13. import { DEFAULT_GRACE_MS, runBash } from './run.ts'
  14. import type { RunInternals, RunningBash } from './run.ts'
  15. export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
  16. export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
  17. /** Plugin config (all optional — `static Config` supplies the defaults). */
  18. export interface Config {
  19. /** Default working directory for commands (default: process.cwd()). */
  20. cwd?: string
  21. /** Default foreground timeout in milliseconds. */
  22. timeoutMs?: number
  23. /** Upper bound for per-call timeout overrides. */
  24. maxTimeoutMs?: number
  25. /** Per-stream in-memory output cap; overflow spills to a temp file. */
  26. maxOutputBytes?: number
  27. /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
  28. graceMs?: number
  29. }
  30. /** The shape after schemastery applied the defaults (cwd has none). */
  31. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  32. function assertPositiveFinite(name: string, value: number): void {
  33. if (!Number.isFinite(value) || value <= 0) {
  34. throw new Error(`bash-local: ${name} must be a positive finite number`)
  35. }
  36. }
  37. interface TrackedTask extends BashTask {
  38. running: RunningBash
  39. /** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
  40. stdoutOffset: number
  41. stderrOffset: number
  42. /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
  43. owner: OwnerToken | undefined
  44. }
  45. /**
  46. * Local-subprocess bash executor. Defaults follow the agent-tool survey
  47. * consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
  48. * in-memory output with full-stream spill files (pi, OpenCode),
  49. * process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode).
  50. */
  51. export class LocalBashExecutor extends BashExecutor {
  52. static Config: z<Config> = z.object({
  53. cwd: z.string(),
  54. timeoutMs: z.number().default(120_000),
  55. maxTimeoutMs: z.number().default(600_000),
  56. maxOutputBytes: z.number().default(64_000),
  57. graceMs: z.number().default(DEFAULT_GRACE_MS),
  58. })
  59. private tasks = new Map<BashTaskId, TrackedTask>()
  60. private nextTaskId = 1
  61. /** Test seam: spill knobs forwarded to runBash. */
  62. internals: RunInternals = {}
  63. /** Validated config (schemastery applied the defaults before construction). */
  64. readonly config: ResolvedConfig
  65. constructor(ctx: Context, config: Config) {
  66. super(ctx)
  67. // schemastery (static Config) has already filled the defaulted fields;
  68. // the cast records that runtime fact for exactOptionalPropertyTypes.
  69. this.config = config as ResolvedConfig
  70. assertPositiveFinite('timeoutMs', this.config.timeoutMs)
  71. assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
  72. assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
  73. assertPositiveFinite('graceMs', this.config.graceMs)
  74. ctx.effect(() => async () => {
  75. // Kill every live process group and WAIT for the processes to close so nothing outlives
  76. // the fiber (HMR safety) — a TERM-trapping child is held until the SIGKILL escalation
  77. // lands.
  78. const pending: Promise<void>[] = []
  79. for (const task of this.tasks.values()) {
  80. if (task.status === 'running') {
  81. task.status = 'killed'
  82. task.running.kill()
  83. pending.push(task.done)
  84. }
  85. }
  86. this.tasks.clear()
  87. await Promise.all(pending)
  88. }, 'local bash teardown')
  89. }
  90. /**
  91. * Resolve a request into a fully-specified spec: fill `workdir` from
  92. * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
  93. * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
  94. * this before {@link run}/{@link start}, so those methods receive explicit
  95. * values and never re-default.
  96. */
  97. resolve(request: BashExecRequest): BashExecSpec {
  98. const timeoutMs = clampTimeout(
  99. request.timeoutMs,
  100. this.config.timeoutMs,
  101. this.config.maxTimeoutMs,
  102. 'bash-local: request.timeoutMs',
  103. )
  104. return {
  105. command: request.command,
  106. workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
  107. timeoutMs,
  108. ...request.signal ? { signal: request.signal } : {},
  109. // Carry stdin/env through verbatim — optional, no config default (absent
  110. // means none). env merges AFTER the scrub in run.ts.
  111. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  112. ...request.env !== undefined ? { env: request.env } : {},
  113. // Carry the owner through verbatim (required-but-nullable on the spec):
  114. // the executor never interprets it — the consumer's access policy does.
  115. owner: request.owner,
  116. // Carry a sandbox-mode override through verbatim: this executor never
  117. // confines, so the field is inert here (the seam contract) — a
  118. // sandboxing subclass overrides resolve() to stamp its default instead.
  119. sandboxMode: request.sandboxMode,
  120. }
  121. }
  122. async run(spec: BashExecSpec): Promise<BashRunResult> {
  123. // One fused deadline drives both the timeout and upstream cancellation;
  124. // runBash listens on d.signal and runs the SIGTERM→grace→SIGKILL kill.
  125. // `using` clears the timer across the awaited process lifetime.
  126. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
  127. const outcome = await runBash({
  128. command: spec.command,
  129. cwd: spec.workdir,
  130. maxOutputBytes: this.config.maxOutputBytes,
  131. graceMs: this.config.graceMs,
  132. signal: d.signal,
  133. stdin: spec.stdin,
  134. env: spec.env,
  135. }, this.internals).done
  136. // Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our timeout cut the
  137. // command short; any other abort — an upstream cancel, or a foreign (outer) deadline's
  138. // timeout under nesting — is aborted.
  139. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
  140. const aborted = d.signal.aborted && !timedOut
  141. return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
  142. }
  143. start(spec: BashExecSpec): BashTask {
  144. // No timeout for background tasks (matches Claude Code, which detaches the timeout when
  145. // backgrounding); callers stop tasks via kill() — or via spec.signal, which the seam
  146. // contract honors for background runs too (runBash wires it to the group kill).
  147. const running = runBash({
  148. command: spec.command,
  149. cwd: spec.workdir,
  150. maxOutputBytes: this.config.maxOutputBytes,
  151. graceMs: this.config.graceMs,
  152. signal: spec.signal,
  153. stdin: spec.stdin,
  154. env: spec.env,
  155. }, this.internals)
  156. const id = BashTaskId(`bash-${this.nextTaskId++}`)
  157. const task: TrackedTask = {
  158. id,
  159. command: spec.command,
  160. status: 'running',
  161. exitCode: null,
  162. signal: null,
  163. owner: spec.owner,
  164. running,
  165. stdoutOffset: 0,
  166. stderrOffset: 0,
  167. done: running.done.then((outcome) => {
  168. // Abort-killed tasks report as killed, not completed. Background runs
  169. // forward only the upstream signal (no timeout), so its aborted state
  170. // is the authoritative "was this cancelled" signal.
  171. if (task.status === 'running') task.status = spec.signal?.aborted === true ? 'killed' : 'completed'
  172. task.exitCode = outcome.exitCode
  173. task.signal = outcome.signal
  174. this.notifyTaskDone(task)
  175. }, (error: unknown) => {
  176. // Spawn-level failure (bad workdir, …): the task never ran. String()
  177. // suffices — runBash only rejects with Error instances.
  178. task.status = 'killed'
  179. task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
  180. this.notifyTaskDone(task)
  181. }),
  182. }
  183. this.tasks.set(id, task)
  184. return task
  185. }
  186. get(id: BashTaskId): BashTask | undefined {
  187. return this.tasks.get(id)
  188. }
  189. /**
  190. * Full collected stderr of a tracked task from stream start (bounded by the
  191. * in-memory cap; bytes only in the spill file are not re-read). A protected
  192. * seam for subclasses that classify a settled task's outcome — reading here
  193. * does NOT advance the consumer's {@link readOutput} cursor. An unknown id
  194. * (a task already dropped by disposal) reads as empty.
  195. */
  196. protected collectedStderr(id: BashTaskId): string {
  197. const task = this.tasks.get(id)
  198. return task === undefined ? '' : task.running.stderr.readFrom(0).text
  199. }
  200. ownerOf(id: BashTaskId): OwnerToken | undefined {
  201. // Unknown id and known-but-ownerless both read as undefined — the consumer
  202. // treats undefined as "open" and a truly unknown id fails at readOutput/kill.
  203. return this.tasks.get(id)?.owner
  204. }
  205. list(): BashTask[] {
  206. return [...this.tasks.values()]
  207. }
  208. readOutput(id: BashTaskId): BashTaskRead {
  209. const task = this.tasks.get(id)
  210. if (!task) throw new Error(`unknown bash task "${id}"`)
  211. const out = task.running.stdout.readFrom(task.stdoutOffset)
  212. const err = task.running.stderr.readFrom(task.stderrOffset)
  213. task.stdoutOffset = out.nextOffset
  214. task.stderrOffset = err.nextOffset
  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. + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
  220. return {
  221. task,
  222. delta,
  223. lossy: out.lossy || err.lossy,
  224. ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
  225. ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
  226. }
  227. }
  228. kill(id: BashTaskId): boolean {
  229. const task = this.tasks.get(id)
  230. if (!task) throw new Error(`unknown bash task "${id}"`)
  231. if (task.status !== 'running') return false
  232. task.status = 'killed'
  233. task.running.kill()
  234. return true
  235. }
  236. }
  237. export default LocalBashExecutor