index.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. /**
  2. * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn
  3. * an external agent as a child process and must keep the parent deployment's
  4. * credentials out of it, tear it down to quiescence, and isolate it from the
  5. * host user's on-disk CLI state. The pieces: the credential env scrub
  6. * ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure
  7. * capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} /
  8. * {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder
  9. * ({@link disposeChildProcess}), and the per-run isolated config dir
  10. * ({@link createIsolatedConfigDir}).
  11. *
  12. * This package owns no provider and registers nothing; it is a pure library
  13. * the out-of-process backend packages depend on (the `subagent-inprocess`
  14. * shape, for the process boundary). Every tunable — the ladder's grace
  15. * periods, a pinned config dir — is a PARAMETER here: defaults belong in each
  16. * consuming plugin's Config, per the no-hardcoded-tunables rule.
  17. *
  18. * @module @deepseek-ai/dsh-subagent-subprocess
  19. */
  20. import type { ChildProcess } from 'node:child_process'
  21. import { mkdtemp, rm } from 'node:fs/promises'
  22. import { tmpdir } from 'node:os'
  23. import { join } from 'node:path'
  24. /**
  25. * Credential-shaped ambient env vars are NOT forwarded to a child by default
  26. * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a
  27. * spawned process implicitly). Same pattern as the bash executor. The child
  28. * agent needs its OWN credentials to reach a model — those are supplied
  29. * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER
  30. * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental
  31. * `AWS_SECRET_ACCESS_KEY` does not.
  32. */
  33. export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
  34. /**
  35. * The ambient env minus credential-shaped vars, plus the caller's explicit
  36. * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so
  37. * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names
  38. * are dropped.
  39. * @param extra - explicit vars layered on top AFTER the scrub, so a
  40. * credential-shaped name supplied deliberately still reaches the child.
  41. * @returns the environment to spawn the child with.
  42. */
  43. export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
  44. const env: NodeJS.ProcessEnv = {}
  45. for (const [key, value] of Object.entries(process.env)) {
  46. if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
  47. }
  48. return { ...env, ...extra }
  49. }
  50. /**
  51. * Capture the child's spawn-level failure as a promise the run's result path
  52. * can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an
  53. * `error` EVENT, not a thrown exception — and without a listener Node treats
  54. * it as an unhandled error and crashes the parent process. Call this in the
  55. * SAME TICK as `spawn()`, so no window exists for the event to fire unheard.
  56. * @param child - the just-spawned child process.
  57. * @returns a promise that RESOLVES (never rejects) with the child's first
  58. * `error` event; for a child that spawns cleanly it never settles.
  59. */
  60. export function spawnFailure(child: ChildProcess): Promise<Error> {
  61. return new Promise<Error>((resolve) => {
  62. child.once('error', (err) => { resolve(err) })
  63. })
  64. }
  65. /**
  66. * Resolve once the child process exits (any code/signal); immediate if it is
  67. * already gone.
  68. * @param child - the child process to await.
  69. */
  70. export function waitForExit(child: ChildProcess): Promise<void> {
  71. if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
  72. return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
  73. }
  74. /**
  75. * Race the child's exit against a timer. Neither outcome leaves anything
  76. * behind on the child: the exit listener is removed on timeout and the timer
  77. * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll
  78. * loop) never accumulate listeners.
  79. * @param child - the child process to watch.
  80. * @param ms - the wait window in milliseconds.
  81. * @returns `true` if the child exits within `ms` (immediately if it is
  82. * already gone), `false` on timeout.
  83. */
  84. export function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
  85. if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
  86. return new Promise<boolean>((resolve) => {
  87. const onExit = (): void => {
  88. clearTimeout(timer)
  89. resolve(true)
  90. }
  91. // `.unref()` so a pending grace timer never keeps the parent's loop alive.
  92. const timer = setTimeout(() => {
  93. child.removeListener('exit', onExit)
  94. resolve(false)
  95. }, ms).unref()
  96. child.once('exit', onExit)
  97. })
  98. }
  99. /**
  100. * The two grace periods of the dispose ladder, supplied per call by the
  101. * consuming backend — each plugin carries them as defaulted, validated
  102. * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is
  103. * deployment-tunable and this library hardcodes nothing.
  104. */
  105. export interface DisposeLadderGraces {
  106. /**
  107. * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
  108. * ON ITS OWN — flush durable state, tear down its own nested subprocesses —
  109. * before the parent escalates to `SIGTERM`. A separate (usually WIDER)
  110. * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
  111. * child's EOF-driven teardown may itself be waiting on a signal-trapping
  112. * grandchild plus a final flush, needing more than one signal-grace of
  113. * headroom.
  114. */
  115. disposeEofGraceMs: number
  116. /** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */
  117. disposeGraceMs: number
  118. }
  119. /**
  120. * Tear a child process down to QUIESCENCE: resolves only once the child has
  121. * actually exited (or was already gone), never merely after requesting it.
  122. * Three-tier escalation —
  123. *
  124. * 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a
  125. * cooperative child quiesces on its own, its teardown and flushes intact;
  126. * 2. `SIGTERM`, then wait `disposeGraceMs`;
  127. * 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF
  128. * and traps `SIGTERM` must not wedge dispose forever.
  129. *
  130. * @param child - the child process to tear down.
  131. * @param graces - the two grace periods, from the consuming plugin's Config.
  132. */
  133. export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
  134. // Already gone: nothing to reap.
  135. if (child.exitCode !== null || child.signalCode !== null) return
  136. // 1. Graceful: end the request stream (stdin EOF) and let the child quiesce
  137. // on its own. Sending SIGTERM in the same tick (or too soon) would
  138. // default-terminate a cooperative child mid-flush, orphaning its nested
  139. // work. A child spawned without a stdin pipe skips straight to the wait.
  140. child.stdin?.end()
  141. if (await exitsWithin(child, graces.disposeEofGraceMs)) return
  142. // 2. SIGTERM, escalating if the child still does not exit within the grace.
  143. child.kill('SIGTERM')
  144. if (await exitsWithin(child, graces.disposeGraceMs)) return
  145. // 3. Force-kill and await the (now-certain) exit.
  146. child.kill('SIGKILL')
  147. await waitForExit(child)
  148. }
  149. /**
  150. * A per-run config directory handle for an external CLI child — the target of
  151. * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to
  152. * the child's environment; call {@link remove} on dispose.
  153. */
  154. export interface IsolatedConfigDir {
  155. /** The directory to point the child at. */
  156. path: string
  157. /**
  158. * Best-effort cleanup: removes the directory (recursively) iff this handle
  159. * CREATED it — a pinned directory is never removed. Idempotent; never
  160. * rejects (a leftover dir under the OS temp root is preferable to a failed
  161. * dispose).
  162. */
  163. remove(): Promise<void>
  164. }
  165. /**
  166. * An isolated config dir for one child run, so the child's behavior is a
  167. * function of deployment config alone — never of whatever `~/.claude` /
  168. * `~/.codex`-style state happens to exist on the host machine. Two modes:
  169. *
  170. * - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp`
  171. * dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it
  172. * best-effort;
  173. * - `pinnedPath` set (a deployment deliberately sharing state across runs):
  174. * the pinned path is returned as-is — never created, never removed — the
  175. * deployment owns that directory's lifecycle.
  176. *
  177. * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g.
  178. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set.
  179. * @param pinnedPath - a deployment-pinned directory to use instead of a
  180. * fresh one.
  181. * @returns the directory handle: `path` for the child env, `remove()` for
  182. * dispose.
  183. */
  184. export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise<IsolatedConfigDir> {
  185. if (pinnedPath !== undefined) {
  186. return {
  187. path: pinnedPath,
  188. remove(): Promise<void> {
  189. // A pinned dir is deployment-owned state (config the user asked to
  190. // persist across runs); removing it here would destroy it. No-op.
  191. return Promise.resolve()
  192. },
  193. }
  194. }
  195. const path = await mkdtemp(join(tmpdir(), prefix))
  196. return {
  197. path,
  198. async remove(): Promise<void> {
  199. try {
  200. await rm(path, { recursive: true, force: true })
  201. } catch {
  202. // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style —
  203. // e.g. the dead child left an unreadable entry behind). The dir lives
  204. // under the OS temp root, which reclaims it; failing dispose over
  205. // cleanup would be worse than a leftover temp dir.
  206. }
  207. },
  208. }
  209. }