1
0

subagent-subprocess.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { EventEmitter } from 'node:events'
  3. import { existsSync } from 'node:fs'
  4. import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import type { ChildProcess } from 'node:child_process'
  8. import {
  9. buildChildEnv,
  10. createIsolatedConfigDir,
  11. disposeChildProcess,
  12. exitsWithin,
  13. SENSITIVE_ENV_PATTERN,
  14. spawnFailure,
  15. waitForExit,
  16. } from '../src/index.ts'
  17. // `rm` is wrapped (real-passthrough by default) so ONE test can inject a
  18. // rejection deterministically. A real recursive-rm failure is not portably
  19. // provokable — permission tricks (a chmod-000 subtree) fail only for
  20. // unprivileged users and are ignored by root — so this is the fs boundary
  21. // the testing policy sanctions mocking; everything else stays the real fs.
  22. vi.mock('node:fs/promises', async (importOriginal) => {
  23. const actual = await importOriginal<typeof import('node:fs/promises')>()
  24. return { ...actual, rm: vi.fn(actual.rm) }
  25. })
  26. /**
  27. * Unit tests for the shared out-of-process machinery. The env scrub and the
  28. * isolated-config-dir helpers run against the REAL process env and REAL
  29. * filesystem (one exception: the rm-failure path injects its rejection at the
  30. * mocked fs boundary, see above); the exit waits and the dispose ladder run
  31. * against a scriptable fake child so each escalation tier's timing is driven
  32. * deterministically (the ACP backend's suite exercises the same ladder
  33. * against real subprocesses end to end).
  34. */
  35. /** What fells a scripted {@link FakeChild}. */
  36. type LethalTrigger = 'eof' | NodeJS.Signals
  37. /** Per-scenario script for a {@link FakeChild}. */
  38. interface FakeChildScript {
  39. /**
  40. * The one trigger that makes the child exit (SIGKILL always does,
  41. * uncatchable, like a real process). Omitted: only SIGKILL fells it.
  42. */
  43. diesOn?: LethalTrigger
  44. /** Delay (ms) between the lethal trigger and the exit event. */
  45. delayMs?: number
  46. /** `false` models a child spawned without a stdin pipe. */
  47. stdin?: boolean
  48. }
  49. /**
  50. * A scriptable stand-in for a ChildProcess carrying exactly the surface the
  51. * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
  52. * `exit` event.
  53. */
  54. class FakeChild extends EventEmitter {
  55. exitCode: number | null = null
  56. signalCode: NodeJS.Signals | null = null
  57. readonly kills: NodeJS.Signals[] = []
  58. stdinEnded = false
  59. readonly stdin: { end: () => void } | null
  60. constructor(private readonly script: FakeChildScript = {}) {
  61. super()
  62. this.stdin = script.stdin === false
  63. ? null
  64. : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
  65. }
  66. kill(signal: NodeJS.Signals): boolean {
  67. this.kills.push(signal)
  68. this.maybeDie(signal)
  69. return true
  70. }
  71. private maybeDie(trigger: LethalTrigger): void {
  72. // SIGKILL is uncatchable — it always fells the child; any other trigger
  73. // only when the scenario scripts it as the lethal one.
  74. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
  75. setTimeout(() => {
  76. if (trigger === 'eof') this.exitCode = 0
  77. else this.signalCode = trigger
  78. this.emit('exit', this.exitCode, this.signalCode)
  79. }, this.script.delayMs ?? 0)
  80. }
  81. }
  82. /** The helpers take a real ChildProcess; the fake carries the read surface. */
  83. function asChild(fake: FakeChild): ChildProcess {
  84. return fake as unknown as ChildProcess
  85. }
  86. describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => {
  87. it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
  88. process.env.DSH_PROC_TEST_API_KEY = 'leak'
  89. process.env.dsh_proc_test_secret = 'leak'
  90. process.env.DSH_PROC_TEST_TOKEN = 'leak'
  91. try {
  92. const env = buildChildEnv({})
  93. expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
  94. expect(env.dsh_proc_test_secret).toBeUndefined()
  95. expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
  96. } finally {
  97. delete process.env.DSH_PROC_TEST_API_KEY
  98. delete process.env.dsh_proc_test_secret
  99. delete process.env.DSH_PROC_TEST_TOKEN
  100. }
  101. })
  102. it('forwards normal ambient vars', () => {
  103. expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false)
  104. expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
  105. })
  106. it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
  107. process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
  108. try {
  109. const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
  110. // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
  111. expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
  112. } finally {
  113. delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
  114. }
  115. })
  116. it('an extra overrides the ambient value of a non-credential var', () => {
  117. process.env.DSH_PROC_TEST_PLAIN = 'ambient'
  118. try {
  119. expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
  120. } finally {
  121. delete process.env.DSH_PROC_TEST_PLAIN
  122. }
  123. })
  124. })
  125. describe('spawnFailure', () => {
  126. it('resolves (never rejects) with the first error event', async () => {
  127. const fake = new FakeChild()
  128. const failure = spawnFailure(asChild(fake))
  129. const err = new Error('spawn ENOENT')
  130. fake.emit('error', err)
  131. await expect(failure).resolves.toBe(err)
  132. })
  133. it('never settles for a child that spawns cleanly and exits', async () => {
  134. const fake = new FakeChild({ diesOn: 'SIGTERM' })
  135. const failure = spawnFailure(asChild(fake))
  136. fake.kill('SIGTERM')
  137. await waitForExit(asChild(fake))
  138. // A clean lifecycle emits `exit`, never `error` — the capture stays
  139. // pending forever, so a race against it is decided by the other arms.
  140. const settled = await Promise.race([
  141. failure.then(() => 'settled'),
  142. new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
  143. ])
  144. expect(settled).toBe('pending')
  145. })
  146. })
  147. describe('waitForExit / exitsWithin', () => {
  148. it('resolves immediately for a child that already exited by code', async () => {
  149. const fake = new FakeChild()
  150. fake.exitCode = 0
  151. await expect(waitForExit(asChild(fake))).resolves.toBeUndefined()
  152. })
  153. it('resolves immediately for a child that already died by signal', async () => {
  154. const fake = new FakeChild()
  155. fake.signalCode = 'SIGTERM'
  156. await expect(waitForExit(asChild(fake))).resolves.toBeUndefined()
  157. })
  158. it('resolves on the exit event of a live child', async () => {
  159. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  160. const exited = waitForExit(asChild(fake))
  161. fake.kill('SIGTERM')
  162. await expect(exited).resolves.toBeUndefined()
  163. expect(fake.signalCode).toBe('SIGTERM')
  164. })
  165. it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => {
  166. const fake = new FakeChild()
  167. fake.exitCode = 0
  168. await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true)
  169. expect(fake.listenerCount('exit')).toBe(0)
  170. })
  171. it('exitsWithin resolves true when the child exits inside the window', async () => {
  172. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  173. fake.kill('SIGTERM')
  174. await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true)
  175. // The once-listener fired and the grace timer was cleared — nothing lingers.
  176. expect(fake.listenerCount('exit')).toBe(0)
  177. })
  178. it('exitsWithin resolves false on timeout for a child that never exits', async () => {
  179. const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent
  180. await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false)
  181. // The timeout arm removed its exit listener: repeated waits (a poll loop,
  182. // the ladder's tiers) never accumulate listeners on the same child.
  183. expect(fake.listenerCount('exit')).toBe(0)
  184. })
  185. })
  186. describe('disposeChildProcess', () => {
  187. it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
  188. const fake = new FakeChild()
  189. fake.exitCode = 0
  190. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  191. expect(fake.stdinEnded).toBe(false)
  192. expect(fake.kills).toEqual([])
  193. })
  194. it('returns immediately for a child already dead by signal', async () => {
  195. const fake = new FakeChild()
  196. fake.signalCode = 'SIGKILL'
  197. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  198. expect(fake.stdinEnded).toBe(false)
  199. expect(fake.kills).toEqual([])
  200. })
  201. it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
  202. const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
  203. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  204. expect(fake.stdinEnded).toBe(true)
  205. expect(fake.kills).toEqual([])
  206. expect(fake.exitCode).toBe(0)
  207. })
  208. it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
  209. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  210. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
  211. expect(fake.stdinEnded).toBe(true)
  212. expect(fake.kills).toEqual(['SIGTERM'])
  213. expect(fake.signalCode).toBe('SIGTERM')
  214. })
  215. it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
  216. const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
  217. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
  218. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  219. // Quiescence, not a request: at resolution the child has ACTUALLY exited
  220. // (the exit event landed, despite the scripted post-SIGKILL delay).
  221. expect(fake.signalCode).toBe('SIGKILL')
  222. })
  223. it('walks the ladder for a child spawned without a stdin pipe', async () => {
  224. const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
  225. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
  226. expect(fake.kills).toEqual(['SIGTERM'])
  227. })
  228. })
  229. describe('createIsolatedConfigDir', () => {
  230. it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
  231. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  232. try {
  233. expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
  234. const st = await stat(dir.path)
  235. expect(st.isDirectory()).toBe(true)
  236. // Private (0700) per the defensive-patterns temp-dir rule.
  237. expect(st.mode & 0o777).toBe(0o700)
  238. } finally {
  239. await dir.remove()
  240. }
  241. })
  242. it('creates a distinct dir per call (per-run isolation)', async () => {
  243. const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  244. const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  245. try {
  246. expect(a.path).not.toBe(b.path)
  247. } finally {
  248. await a.remove()
  249. await b.remove()
  250. }
  251. })
  252. it('remove() deletes a fresh dir recursively and is idempotent', async () => {
  253. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  254. await writeFile(join(dir.path, 'settings.json'), '{}')
  255. await dir.remove()
  256. expect(existsSync(dir.path)).toBe(false)
  257. // Second remove: nothing left to delete, still resolves.
  258. await expect(dir.remove()).resolves.toBeUndefined()
  259. })
  260. it('returns a pinned dir verbatim and NEVER removes it', async () => {
  261. const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
  262. try {
  263. const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
  264. expect(dir.path).toBe(pinned)
  265. await dir.remove()
  266. // The deployment owns a pinned dir's lifecycle — remove() must not touch it.
  267. expect(existsSync(pinned)).toBe(true)
  268. } finally {
  269. await rm(pinned, { recursive: true, force: true })
  270. }
  271. })
  272. it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
  273. const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
  274. const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
  275. expect(dir.path).toBe(missing)
  276. expect(existsSync(missing)).toBe(false)
  277. await dir.remove()
  278. expect(existsSync(missing)).toBe(false)
  279. })
  280. it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
  281. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
  282. try {
  283. // The swallow contract is error-kind agnostic; EACCES stands in for the
  284. // family (EBUSY, a vanished mount, …) that best-effort must absorb.
  285. vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
  286. await expect(dir.remove()).resolves.toBeUndefined()
  287. // The injected rejection consumed the only rm call — nothing was deleted.
  288. expect(existsSync(dir.path)).toBe(true)
  289. } finally {
  290. await rm(dir.path, { recursive: true, force: true })
  291. }
  292. })
  293. })