subagent-subprocess.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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. spawnFailure,
  13. } from '../src/index.ts'
  14. // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
  15. // failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
  16. vi.mock('node:fs/promises', async (importOriginal) => {
  17. const actual = await importOriginal<typeof import('node:fs/promises')>()
  18. return { ...actual, rm: vi.fn(actual.rm) }
  19. })
  20. /**
  21. * Unit tests for the shared out-of-process machinery. The env scrub and the
  22. * isolated-config-dir helpers run against the REAL process env and REAL
  23. * filesystem (one exception: the rm-failure path injects its rejection at the
  24. * mocked fs boundary, see above); the exit waits and the dispose ladder run
  25. * against a scriptable fake child so each escalation tier's timing is driven
  26. * deterministically (the ACP backend's suite exercises the same ladder
  27. * against real subprocesses end to end).
  28. */
  29. /** What fells a scripted {@link FakeChild}. */
  30. type LethalTrigger = 'eof' | NodeJS.Signals
  31. /** Per-scenario script for a {@link FakeChild}. */
  32. interface FakeChildScript {
  33. /**
  34. * The one trigger that makes the child exit (SIGKILL always does,
  35. * uncatchable, like a real process). Omitted: only SIGKILL fells it.
  36. */
  37. diesOn?: LethalTrigger
  38. /** Delay (ms) between the lethal trigger and the exit event. */
  39. delayMs?: number
  40. /** Complete the scripted exit inside the triggering call. */
  41. synchronousExit?: boolean
  42. /** `false` models a child spawned without a stdin pipe. */
  43. stdin?: boolean
  44. }
  45. /**
  46. * A scriptable stand-in for a ChildProcess carrying exactly the surface the
  47. * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
  48. * `exit` event.
  49. */
  50. class FakeChild extends EventEmitter {
  51. exitCode: number | null = null
  52. signalCode: NodeJS.Signals | null = null
  53. readonly kills: NodeJS.Signals[] = []
  54. stdinEnded = false
  55. readonly stdin: { end: () => void } | null
  56. constructor(private readonly script: FakeChildScript = {}) {
  57. super()
  58. this.stdin = script.stdin === false
  59. ? null
  60. : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
  61. }
  62. kill(signal: NodeJS.Signals): boolean {
  63. this.kills.push(signal)
  64. this.maybeDie(signal)
  65. return true
  66. }
  67. private maybeDie(trigger: LethalTrigger): void {
  68. // SIGKILL is uncatchable — it always fells the child; any other trigger
  69. // only when the scenario scripts it as the lethal one.
  70. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
  71. const exit = (): void => {
  72. if (trigger === 'eof') this.exitCode = 0
  73. else this.signalCode = trigger
  74. this.emit('exit', this.exitCode, this.signalCode)
  75. }
  76. if (this.script.synchronousExit === true) exit()
  77. else setTimeout(exit, this.script.delayMs ?? 0)
  78. }
  79. }
  80. /** The helpers take a real ChildProcess; the fake carries the read surface. */
  81. function asChild(fake: FakeChild): ChildProcess {
  82. return fake as unknown as ChildProcess
  83. }
  84. describe('buildChildEnv', () => {
  85. it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
  86. process.env.DSH_PROC_TEST_API_KEY = 'leak'
  87. process.env.dsh_proc_test_secret = 'leak'
  88. process.env.DSH_PROC_TEST_TOKEN = 'leak'
  89. try {
  90. const env = buildChildEnv({})
  91. expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
  92. expect(env.dsh_proc_test_secret).toBeUndefined()
  93. expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
  94. } finally {
  95. delete process.env.DSH_PROC_TEST_API_KEY
  96. delete process.env.dsh_proc_test_secret
  97. delete process.env.DSH_PROC_TEST_TOKEN
  98. }
  99. })
  100. it('forwards normal ambient vars', () => {
  101. expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
  102. })
  103. it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
  104. process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
  105. try {
  106. const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
  107. // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
  108. expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
  109. } finally {
  110. delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
  111. }
  112. })
  113. it('an extra overrides the ambient value of a non-credential var', () => {
  114. process.env.DSH_PROC_TEST_PLAIN = 'ambient'
  115. try {
  116. expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
  117. } finally {
  118. delete process.env.DSH_PROC_TEST_PLAIN
  119. }
  120. })
  121. })
  122. describe('spawnFailure', () => {
  123. it('resolves (never rejects) with the first error event', async () => {
  124. const fake = new FakeChild()
  125. const failure = spawnFailure(asChild(fake))
  126. const err = new Error('spawn ENOENT')
  127. fake.emit('error', err)
  128. await expect(failure).resolves.toBe(err)
  129. })
  130. it('never settles for a child that spawns cleanly and exits', async () => {
  131. const fake = new FakeChild({ diesOn: 'SIGTERM' })
  132. const failure = spawnFailure(asChild(fake))
  133. fake.kill('SIGTERM')
  134. await new Promise<void>(resolve => fake.once('exit', () => { resolve() }))
  135. // A clean lifecycle emits `exit`, never `error` — the capture stays
  136. // pending forever, so a race against it is decided by the other arms.
  137. const settled = await Promise.race([
  138. failure.then(() => 'settled'),
  139. new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
  140. ])
  141. expect(settled).toBe('pending')
  142. })
  143. })
  144. describe('disposeChildProcess', () => {
  145. it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
  146. const fake = new FakeChild()
  147. fake.exitCode = 0
  148. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  149. expect(fake.stdinEnded).toBe(false)
  150. expect(fake.kills).toEqual([])
  151. })
  152. it('returns immediately for a child already dead by signal', async () => {
  153. const fake = new FakeChild()
  154. fake.signalCode = 'SIGKILL'
  155. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  156. expect(fake.stdinEnded).toBe(false)
  157. expect(fake.kills).toEqual([])
  158. })
  159. it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
  160. const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
  161. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  162. expect(fake.stdinEnded).toBe(true)
  163. expect(fake.kills).toEqual([])
  164. expect(fake.exitCode).toBe(0)
  165. })
  166. it('recognizes a child that exits synchronously on stdin EOF', async () => {
  167. const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
  168. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  169. expect(fake.exitCode).toBe(0)
  170. expect(fake.listenerCount('exit')).toBe(0)
  171. })
  172. it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
  173. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  174. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  175. expect(fake.stdinEnded).toBe(true)
  176. expect(fake.kills).toEqual(['SIGTERM'])
  177. expect(fake.signalCode).toBe('SIGTERM')
  178. expect(fake.listenerCount('exit')).toBe(0)
  179. })
  180. it('recognizes a child that exits synchronously on SIGTERM', async () => {
  181. const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
  182. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  183. expect(fake.kills).toEqual(['SIGTERM'])
  184. expect(fake.signalCode).toBe('SIGTERM')
  185. expect(fake.listenerCount('exit')).toBe(0)
  186. })
  187. it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
  188. const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
  189. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  190. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  191. // Quiescence, not a request: at resolution the child has ACTUALLY exited
  192. // (the exit event landed, despite the scripted post-SIGKILL delay).
  193. expect(fake.signalCode).toBe('SIGKILL')
  194. })
  195. it('recognizes a child already gone when the final exit wait begins', async () => {
  196. const fake = new FakeChild({ synchronousExit: true })
  197. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  198. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  199. expect(fake.signalCode).toBe('SIGKILL')
  200. })
  201. it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
  202. const fake = new FakeChild()
  203. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  204. fake.kills.push(signal)
  205. queueMicrotask(() => {
  206. if (marker === 'exitCode') fake.exitCode = 0
  207. else fake.signalCode = 'SIGTERM'
  208. })
  209. return true
  210. })
  211. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
  212. expect(fake.kills).toEqual(['SIGTERM'])
  213. })
  214. it('walks the ladder for a child spawned without a stdin pipe', async () => {
  215. const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
  216. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  217. expect(fake.kills).toEqual(['SIGTERM'])
  218. })
  219. it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
  220. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  221. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
  222. expect(fake.kills).toEqual(['SIGKILL'])
  223. expect(fake.signalCode).toBe('SIGKILL')
  224. })
  225. it('propagates a forced-termination error without waiting for the grace', async () => {
  226. const fake = new FakeChild()
  227. const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
  228. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  229. fake.kills.push(signal)
  230. fake.emit('error', failure)
  231. return false
  232. })
  233. await expect(disposeChildProcess(
  234. asChild(fake),
  235. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  236. 'win32',
  237. )).rejects.toBe(failure)
  238. expect(fake.kills).toEqual(['SIGKILL'])
  239. expect(fake.listenerCount('error')).toBe(0)
  240. expect(fake.listenerCount('exit')).toBe(0)
  241. })
  242. it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
  243. const fake = new FakeChild()
  244. const failure = new Error('invalid signal state')
  245. vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
  246. await expect(disposeChildProcess(
  247. asChild(fake),
  248. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  249. 'win32',
  250. )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
  251. expect(fake.listenerCount('error')).toBe(0)
  252. expect(fake.listenerCount('exit')).toBe(0)
  253. })
  254. it('bounds a refused forced termination that produces no error or exit', async () => {
  255. const fake = new FakeChild()
  256. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  257. fake.kills.push(signal)
  258. return false
  259. })
  260. await expect(disposeChildProcess(
  261. asChild(fake),
  262. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  263. 'win32',
  264. )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
  265. expect(fake.listenerCount('error')).toBe(0)
  266. expect(fake.listenerCount('exit')).toBe(0)
  267. })
  268. it('bounds an accepted forced termination that never reports exit', async () => {
  269. const fake = new FakeChild()
  270. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  271. fake.kills.push(signal)
  272. return true
  273. })
  274. await expect(disposeChildProcess(
  275. asChild(fake),
  276. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  277. 'win32',
  278. )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
  279. expect(fake.listenerCount('error')).toBe(0)
  280. expect(fake.listenerCount('exit')).toBe(0)
  281. })
  282. })
  283. describe('createIsolatedConfigDir', () => {
  284. it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
  285. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  286. try {
  287. expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
  288. const st = await stat(dir.path)
  289. expect(st.isDirectory()).toBe(true)
  290. // Windows reports synthetic POSIX mode bits; privacy comes from the
  291. // inherited directory ACL rather than chmod-compatible mode bits.
  292. if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
  293. } finally {
  294. await dir.remove()
  295. }
  296. })
  297. it('creates a distinct dir per call (per-run isolation)', async () => {
  298. const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  299. const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  300. try {
  301. expect(a.path).not.toBe(b.path)
  302. } finally {
  303. await a.remove()
  304. await b.remove()
  305. }
  306. })
  307. it('remove() deletes a fresh dir recursively and is idempotent', async () => {
  308. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  309. await writeFile(join(dir.path, 'settings.json'), '{}')
  310. await dir.remove()
  311. expect(existsSync(dir.path)).toBe(false)
  312. // Second remove: nothing left to delete, still resolves.
  313. await expect(dir.remove()).resolves.toBeUndefined()
  314. })
  315. it('returns a pinned dir verbatim and NEVER removes it', async () => {
  316. const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
  317. try {
  318. const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
  319. expect(dir.path).toBe(pinned)
  320. await dir.remove()
  321. // The deployment owns a pinned dir's lifecycle — remove() must not touch it.
  322. expect(existsSync(pinned)).toBe(true)
  323. } finally {
  324. await rm(pinned, { recursive: true, force: true })
  325. }
  326. })
  327. it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
  328. const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
  329. const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
  330. expect(dir.path).toBe(missing)
  331. expect(existsSync(missing)).toBe(false)
  332. await dir.remove()
  333. expect(existsSync(missing)).toBe(false)
  334. })
  335. it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
  336. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
  337. try {
  338. // The swallow contract is error-kind agnostic; EACCES stands in for the
  339. // family (EBUSY, a vanished mount, …) that best-effort must absorb.
  340. vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
  341. await expect(dir.remove()).resolves.toBeUndefined()
  342. // The injected rejection consumed the only rm call — nothing was deleted.
  343. expect(existsSync(dir.path)).toBe(true)
  344. } finally {
  345. await rm(dir.path, { recursive: true, force: true })
  346. }
  347. })
  348. })