index.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * `LocalBashExecutor`: the local-subprocess implementation of the
  3. * `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
  4. * own process group (see `./run.ts` for the plumbing and the agent-tool
  5. * survey notes), tracks background tasks, and kills everything on dispose.
  6. *
  7. * TODO(permissions/sandbox): execution policy does NOT belong here — wrap
  8. * the `tools/execute` waterfall (see docs/architecture.md § plugin
  9. * checklist) or implement a sandboxing `BashExecutor`. Reference points:
  10. * Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
  11. * seatbelt/landlock plus an execpolicy prefix-rule engine.
  12. *
  13. * @module @deepseek-ai/dsh-bash-local
  14. */
  15. import { Context } from 'cordis'
  16. import z from 'schemastery'
  17. import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
  18. import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
  19. import { runBash } from './run.ts'
  20. import type { RunInternals, RunningBash } from './run.ts'
  21. export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
  22. export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
  23. /** Plugin config (all optional — `static Config` supplies the defaults). */
  24. export interface Config {
  25. /** Default working directory for commands (default: process.cwd()). */
  26. cwd?: string
  27. /** Default foreground timeout in milliseconds. */
  28. timeoutMs?: number
  29. /** Upper bound for per-call timeout overrides. */
  30. maxTimeoutMs?: number
  31. /** Per-stream in-memory output cap; overflow spills to a temp file. */
  32. maxOutputBytes?: number
  33. }
  34. /** The shape after schemastery applied the defaults (cwd has none). */
  35. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  36. function assertPositiveFinite(name: string, value: number): void {
  37. if (!Number.isFinite(value) || value <= 0) {
  38. throw new Error(`bash-local: ${name} must be a positive finite number`)
  39. }
  40. }
  41. interface TrackedTask extends BashTask {
  42. running: RunningBash
  43. /** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
  44. stdoutOffset: number
  45. stderrOffset: number
  46. /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
  47. owner: OwnerToken | undefined
  48. }
  49. /**
  50. * Local-subprocess bash executor. Defaults follow the agent-tool survey
  51. * consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
  52. * in-memory output with full-stream spill files (pi, OpenCode),
  53. * process-group SIGTERM→SIGKILL kills (OpenCode).
  54. */
  55. export class LocalBashExecutor extends BashExecutor {
  56. static Config: z<Config> = z.object({
  57. cwd: z.string(),
  58. timeoutMs: z.number().default(120_000),
  59. maxTimeoutMs: z.number().default(600_000),
  60. maxOutputBytes: z.number().default(64_000),
  61. })
  62. private tasks = new Map<BashTaskId, TrackedTask>()
  63. private nextTaskId = 1
  64. /** Test seam: timer/spill knobs forwarded to runBash. */
  65. internals: RunInternals = {}
  66. /** Validated config (schemastery applied the defaults before construction). */
  67. readonly config: ResolvedConfig
  68. constructor(ctx: Context, config: Config) {
  69. super(ctx)
  70. // schemastery (static Config) has already filled the defaulted fields;
  71. // the cast records that runtime fact for exactOptionalPropertyTypes.
  72. this.config = config as ResolvedConfig
  73. assertPositiveFinite('timeoutMs', this.config.timeoutMs)
  74. assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
  75. assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
  76. ctx.effect(() => async () => {
  77. // Kill every live process group and WAIT for the processes to close so
  78. // nothing outlives the fiber (HMR safety) — a TERM-trapping child is
  79. // held until the SIGKILL escalation lands. The base class already
  80. // silenced listeners, so these kills complete without notices.
  81. const pending: Promise<void>[] = []
  82. for (const task of this.tasks.values()) {
  83. if (task.status === 'running') {
  84. task.status = 'killed'
  85. task.running.kill()
  86. pending.push(task.done)
  87. }
  88. }
  89. this.tasks.clear()
  90. await Promise.all(pending)
  91. }, 'local bash teardown')
  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. if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
  102. const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
  103. return {
  104. command: request.command,
  105. workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
  106. timeoutMs,
  107. ...request.signal ? { signal: request.signal } : {},
  108. // Carry the trusted-plugin stdin/env through verbatim — optional, no
  109. // config default (absent means none). env merges AFTER the scrub in run.ts.
  110. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  111. ...request.env !== undefined ? { env: request.env } : {},
  112. // Carry the owner through verbatim (required-but-nullable on the spec):
  113. // the executor never interprets it — the consumer's access policy does.
  114. owner: request.owner,
  115. }
  116. }
  117. async run(spec: BashExecSpec): Promise<BashRunResult> {
  118. const outcome = await runBash({
  119. command: spec.command,
  120. cwd: spec.workdir,
  121. timeoutMs: spec.timeoutMs,
  122. maxOutputBytes: this.config.maxOutputBytes,
  123. signal: spec.signal,
  124. stdin: spec.stdin,
  125. env: spec.env,
  126. }, this.internals).done
  127. return { ...outcome, timeoutMs: spec.timeoutMs }
  128. }
  129. start(spec: BashExecSpec): BashTask {
  130. // No timeout for background tasks (matches Claude Code, which detaches
  131. // the timeout when backgrounding); callers stop tasks via kill() — or
  132. // via spec.signal, which the seam contract honors for background runs
  133. // too (runBash wires it to the group kill). spec.timeoutMs is ignored
  134. // here by design.
  135. const running = runBash({
  136. command: spec.command,
  137. cwd: spec.workdir,
  138. timeoutMs: 0,
  139. maxOutputBytes: this.config.maxOutputBytes,
  140. signal: spec.signal,
  141. stdin: spec.stdin,
  142. env: spec.env,
  143. }, this.internals)
  144. const id = BashTaskId(`bash-${this.nextTaskId++}`)
  145. const task: TrackedTask = {
  146. id,
  147. command: spec.command,
  148. status: 'running',
  149. exitCode: null,
  150. signal: null,
  151. owner: spec.owner,
  152. running,
  153. stdoutOffset: 0,
  154. stderrOffset: 0,
  155. done: running.done.then((outcome) => {
  156. // Abort-killed tasks report as killed, not completed.
  157. if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
  158. task.exitCode = outcome.exitCode
  159. task.signal = outcome.signal
  160. this.notifyTaskDone(task)
  161. }, (error: unknown) => {
  162. // Spawn-level failure (bad workdir, …): the task never ran. String()
  163. // suffices — runBash only rejects with Error instances.
  164. task.status = 'killed'
  165. task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
  166. this.notifyTaskDone(task)
  167. }),
  168. }
  169. this.tasks.set(id, task)
  170. return task
  171. }
  172. get(id: BashTaskId): BashTask | undefined {
  173. return this.tasks.get(id)
  174. }
  175. ownerOf(id: BashTaskId): OwnerToken | undefined {
  176. // Unknown id and known-but-ownerless both read as undefined — the consumer
  177. // treats undefined as "open" and a truly unknown id fails at readOutput/kill.
  178. return this.tasks.get(id)?.owner
  179. }
  180. list(): BashTask[] {
  181. return [...this.tasks.values()]
  182. }
  183. readOutput(id: BashTaskId): BashTaskRead {
  184. const task = this.tasks.get(id)
  185. if (!task) throw new Error(`unknown bash task "${id}"`)
  186. const out = task.running.stdout.readFrom(task.stdoutOffset)
  187. const err = task.running.stderr.readFrom(task.stderrOffset)
  188. task.stdoutOffset = out.nextOffset
  189. task.stderrOffset = err.nextOffset
  190. // Single newline between sections: stdout chunks usually end with one
  191. // already; add it only when missing.
  192. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
  193. const delta = out.text
  194. + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
  195. return {
  196. task,
  197. delta,
  198. lossy: out.lossy || err.lossy,
  199. ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
  200. ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
  201. }
  202. }
  203. kill(id: BashTaskId): boolean {
  204. const task = this.tasks.get(id)
  205. if (!task) throw new Error(`unknown bash task "${id}"`)
  206. if (task.status !== 'running') return false
  207. task.status = 'killed'
  208. task.running.kill()
  209. return true
  210. }
  211. }
  212. export default LocalBashExecutor