index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. /**
  2. * Local Service Provider for the bash capability seam over the subprocess
  3. * capability seam. Public commands run as `bash -c` in a provider-managed range
  4. * through `ctx.subprocess`; subclasses may reuse the same mechanics with an
  5. * explicit argv. This executor owns command defaulting, deadlines and cause
  6. * classification, the model-friendly terminal environment, and the model-facing
  7. * stdout/stderr merge for background reads. Execution policy belongs in
  8. * `tools/pre-execute` or a sandboxing executor.
  9. * @module @deepseek-ai/dsh-bash-local
  10. */
  11. import { Context } from '@deepseek-ai/cordis'
  12. import z from '@deepseek-ai/schemastery'
  13. import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell'
  14. import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
  15. import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  16. import type {} from '@deepseek-ai/dsh-settings'
  17. import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
  18. /**
  19. * Model-friendly environment overrides: disable colors, pagers, and
  20. * interactive terminal features that would garble tool output (the same set
  21. * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
  22. * merged first into the spawn's explicit env, so a trusted caller's own entry
  23. * still wins; the subprocess service applies its credential scrub independently.
  24. */
  25. export const ENV_OVERRIDES = {
  26. NO_COLOR: '1',
  27. TERM: 'dumb',
  28. PAGER: 'cat',
  29. GIT_PAGER: 'cat',
  30. } as const
  31. /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
  32. const DEFAULT_GRACE_MS = 3_000
  33. /** Default per-stream spill cap (the `maxSpillBytes` config). */
  34. const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
  35. /** Plugin config (all optional — `static Config` supplies the defaults). */
  36. export interface Config {
  37. /** Default working directory for commands (default: process.cwd()). */
  38. cwd?: string
  39. /** Default foreground timeout in milliseconds. */
  40. timeoutMs?: number
  41. /** Upper bound for per-call timeout overrides. */
  42. maxTimeoutMs?: number
  43. /** Per-stream in-memory output cap; overflow spills to a temp file. */
  44. maxOutputBytes?: number
  45. /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
  46. maxSpillBytes?: number
  47. /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
  48. graceMs?: number
  49. }
  50. /** The shape after schemastery applied the defaults (cwd has none). */
  51. type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
  52. /** Project a settled collect-mode reader into the final CollectedOutput shape. */
  53. function finalOutput(reader: SubprocessOutputReader): CollectedOutput {
  54. const read = reader.readFrom(0)
  55. return {
  56. text: read.text,
  57. truncated: read.lossy,
  58. ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
  59. }
  60. }
  61. function assertPositiveFinite(name: string, value: number): void {
  62. if (!Number.isFinite(value) || value <= 0) {
  63. throw new Error(`bash-local: ${name} must be a positive finite number`)
  64. }
  65. }
  66. /**
  67. * Reject a resolved section this executor could not run with. The schema
  68. * expresses neither "positive and finite" nor the timer bound `graceMs` has to
  69. * fit, so a stored value is refused where it is written instead of failing at
  70. * the next command.
  71. * @param config - the resolved section, schema-valid by construction.
  72. * @throws Error naming the field that cannot be used.
  73. */
  74. export function assertServiceableBashConfig(config: Config): void {
  75. const resolved = config as ResolvedConfig
  76. assertPositiveFinite('timeoutMs', resolved.timeoutMs)
  77. assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
  78. assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
  79. assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
  80. assertPositiveFinite('graceMs', resolved.graceMs)
  81. if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
  82. throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  83. }
  84. }
  85. /**
  86. * Local bash executor over `ctx.subprocess`. Bounded output, spill files,
  87. * managed-range SIGTERM→SIGKILL escalation, and quiescence are the subprocess
  88. * service's mechanics; this executor supplies their configured budgets per spawn, so a
  89. * still-running background process stays managed (killed and joined at
  90. * composition teardown) even across an executor reload.
  91. */
  92. export class LocalBashExecutor extends ShellExecutor {
  93. static inject = ['subprocess']
  94. static Config: z<Config> = z.object({
  95. cwd: z.string(),
  96. timeoutMs: z.number().default(120_000),
  97. maxTimeoutMs: z.number().default(600_000),
  98. maxOutputBytes: z.number().default(64_000),
  99. maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
  100. graceMs: z.number().default(DEFAULT_GRACE_MS),
  101. })
  102. /** The currently authoritative config: the settings section, or the composition entry. */
  103. private source: () => ResolvedConfig
  104. /** Validated config (schemastery applied the defaults before construction). */
  105. get config(): ResolvedConfig {
  106. return this.source()
  107. }
  108. constructor(ctx: Context, config: Config) {
  109. super(ctx)
  110. // Schemastery fills these fields before construction; the type does not encode that step.
  111. const entry = config as ResolvedConfig
  112. assertServiceableBashConfig(entry)
  113. this.source = () => entry
  114. ctx.inject(['settings'], (settingsCtx) => {
  115. settingsCtx.settings.installSection(ctx, SHELL_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
  116. validate: assertServiceableBashConfig,
  117. setSource: (current) => {
  118. this.source = current as () => ResolvedConfig
  119. },
  120. // Every field is read through the getter at each command, so nothing
  121. // derived from the source needs rebuilding when the document changes.
  122. onChange: () => {},
  123. })
  124. })
  125. }
  126. /**
  127. * Resolve a request into a fully-specified spec: fill `workdir` from
  128. * `config.cwd` (else `process.cwd()`), and `timeoutMs` from
  129. * `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
  130. * this before {@link run}/{@link start}, so those methods receive explicit
  131. * values and never re-default.
  132. */
  133. resolve(request: ShellExecRequest): ShellExecSpec {
  134. const timeoutMs = clampTimeout(
  135. request.timeoutMs,
  136. this.config.timeoutMs,
  137. this.config.maxTimeoutMs,
  138. 'bash-local: request.timeoutMs',
  139. )
  140. const stdoutMaxBytes = request.stdoutMaxBytes ?? this.config.maxOutputBytes
  141. assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
  142. return {
  143. command: request.command,
  144. workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
  145. timeoutMs,
  146. stdoutMaxBytes,
  147. ...request.signal ? { signal: request.signal } : {},
  148. // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
  149. // no config default. The subprocess service owns the scrub and merge order.
  150. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  151. ...request.env !== undefined ? { env: request.env } : {},
  152. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  153. // Carry a sandbox policy through verbatim: this executor never
  154. // confines, so the field is inert here (the seam contract) — a
  155. // sandboxing subclass overrides resolve() to stamp its default instead.
  156. sandboxPolicy: request.sandboxPolicy,
  157. }
  158. }
  159. /** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
  160. // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
  161. private spawnSpec(
  162. spec: ShellExecSpec,
  163. argv: readonly string[],
  164. stdoutMaxBytes: number,
  165. signal: AbortSignal | undefined,
  166. ): SubprocessSpawnSpec {
  167. const collect = (maxBytes: number): SubprocessCollect =>
  168. ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
  169. return {
  170. argv,
  171. cwd: spec.workdir,
  172. stdio: {
  173. stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
  174. stdout: collect(stdoutMaxBytes),
  175. stderr: collect(this.config.maxOutputBytes),
  176. },
  177. graceMs: this.config.graceMs,
  178. signal,
  179. // One explicit env map for the seam, layered so the trusted dshEnv
  180. // snapshot beats both the caller's env and the terminal overrides; the
  181. // subprocess service merges the whole map after its ambient scrub.
  182. env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv },
  183. }
  184. }
  185. /** The collect-mode readers the executor itself requested (present by construction). */
  186. private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } {
  187. const { stdout, stderr } = handle.collected
  188. /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */
  189. if (stdout === undefined || stderr === undefined) {
  190. throw new Error('bash-local: subprocess implementation dropped a requested collect stream')
  191. }
  192. /* v8 ignore stop */
  193. return { stdout, stderr }
  194. }
  195. async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  196. return (await this.runArgv(spec, ['bash', '-c', spec.command])).result
  197. }
  198. /**
  199. * Run an explicit argv with the foreground lifecycle, environment, output,
  200. * timeout, and cancellation semantics of this executor. Subclasses use this
  201. * after replacing the public command's shell argv at an execution boundary.
  202. * @param spec - resolved execution settings and caller-owned command metadata.
  203. * @param argvOrPrepare - exact argv, or preparation cancelled by the same deadline as execution.
  204. * @returns the foreground result and whether argv reached the subprocess provider.
  205. */
  206. protected async runArgv(
  207. spec: ShellExecSpec,
  208. argvOrPrepare: readonly string[] | ((signal: AbortSignal) => Promise<readonly string[]>),
  209. ): Promise<{ result: ShellRunResult; spawnRequested: boolean }> {
  210. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
  211. let argv: readonly string[]
  212. if (typeof argvOrPrepare === 'function') {
  213. const cancelled = Promise.withResolvers<never>()
  214. const abort = (): void => { cancelled.reject(d.signal.reason) }
  215. d.signal.addEventListener('abort', abort, { once: true })
  216. try {
  217. argv = await Promise.race([
  218. Promise.resolve().then(() => { d.signal.throwIfAborted(); return argvOrPrepare(d.signal) }),
  219. cancelled.promise,
  220. ])
  221. d.signal.throwIfAborted()
  222. } catch (error) {
  223. if (timeoutOf(d.signal, 'BASH_TIMEOUT') === undefined) throw error
  224. return {
  225. spawnRequested: false,
  226. result: {
  227. exitCode: null, signal: null, timedOut: true, aborted: false, timeoutMs: spec.timeoutMs,
  228. stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false },
  229. },
  230. }
  231. } finally { d.signal.removeEventListener('abort', abort) }
  232. } else { argv = argvOrPrepare }
  233. const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
  234. const outcome = await handle.done
  235. const collected = LocalBashExecutor.collected(handle)
  236. // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
  237. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
  238. const aborted = d.signal.aborted && !timedOut
  239. return {
  240. spawnRequested: true,
  241. result: {
  242. ...outcome,
  243. timedOut,
  244. aborted,
  245. timeoutMs: spec.timeoutMs,
  246. stdout: finalOutput(collected.stdout),
  247. stderr: finalOutput(collected.stderr),
  248. },
  249. }
  250. }
  251. async start(spec: ShellExecSpec): Promise<ShellProcess> {
  252. return Promise.resolve(this.startArgv(spec, ['bash', '-c', spec.command]))
  253. }
  254. /**
  255. * Start an explicit argv with the background lifecycle, environment, output,
  256. * cancellation, and managed-range ownership semantics of this executor.
  257. * Subclasses use this after replacing the public command's shell argv at an
  258. * execution boundary.
  259. * @param spec - resolved execution settings and caller-owned command metadata.
  260. * @param argv - exact executable and arguments to hand to `ctx.subprocess`.
  261. * @returns the live background handle; provider rejection settles it as killed.
  262. */
  263. protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
  264. // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
  265. spec.signal?.throwIfAborted()
  266. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
  267. const collected = LocalBashExecutor.collected(running)
  268. // A provider rejection has no direct outcome to display; its stage is not
  269. // public, so a neutral note is delivered once through the read path.
  270. let providerFailureNote: string | undefined
  271. const consumeProviderFailure = (): string => {
  272. const note = providerFailureNote ?? ''
  273. providerFailureNote = undefined
  274. return note
  275. }
  276. let stdoutOffset = 0
  277. let stderrOffset = 0
  278. const proc: ShellProcess = {
  279. status: 'running',
  280. exitCode: null,
  281. signal: null,
  282. done: running.done.then((outcome) => {
  283. // Any signal termination is killed, including a command signaling itself.
  284. if (proc.status === 'running') {
  285. proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
  286. }
  287. proc.exitCode = outcome.exitCode
  288. proc.signal = outcome.signal
  289. this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
  290. }, (error: unknown) => {
  291. // Background provider failures settle as killed and surface through the read path.
  292. proc.status = 'killed'
  293. let detail = 'unprintable provider failure'
  294. try {
  295. detail = String(error)
  296. } catch {
  297. // Provider-owned rejection values cannot make ShellProcess.done reject.
  298. }
  299. providerFailureNote = `subprocess failed before reporting an outcome: ${detail}`
  300. this.onProcessDone(proc, providerFailureNote, true, error)
  301. }),
  302. readOutput: (): ShellProcessRead => {
  303. const out = collected.stdout.readFrom(stdoutOffset)
  304. const err = collected.stderr.readFrom(stderrOffset)
  305. stdoutOffset = out.nextOffset
  306. stderrOffset = err.nextOffset
  307. const providerFailure = consumeProviderFailure()
  308. const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : ''
  309. const errText = err.text
  310. + (providerFailure.length > 0 ? `${failureSeparator}${providerFailure}` : '')
  311. // Single newline between sections: stdout chunks usually end with one
  312. // already; add it only when missing.
  313. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
  314. const delta = out.text
  315. + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
  316. return {
  317. delta,
  318. lossy: out.lossy || err.lossy,
  319. ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
  320. ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
  321. }
  322. },
  323. kill: (): boolean => {
  324. if (proc.status !== 'running') return false
  325. proc.status = 'killed'
  326. running.terminate()
  327. return true
  328. },
  329. }
  330. return proc
  331. }
  332. /**
  333. * Settlement hook for subclasses that attach execution facts to a process.
  334. * Called after exit facts or provider-failure output are stamped and before
  335. * {@link ShellProcess.done} resolves. The base implementation is intentionally
  336. * empty.
  337. * @param _proc - the settled process handle.
  338. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
  339. * @param _providerRejected - whether the subprocess promise rejected without a direct outcome.
  340. * @param _providerError - the provider rejection reason, which may itself be undefined.
  341. */
  342. protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {}
  343. }
  344. export default LocalBashExecutor