subagent-subprocess.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  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. NO_START_CAPABILITIES,
  13. settleRunResult,
  14. spawnFailure,
  15. subprocessRunHandle,
  16. } from '../src/index.ts'
  17. import { SessionId } from '@deepseek-ai/dsh-session'
  18. // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
  19. // failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
  20. vi.mock('node:fs/promises', async (importOriginal) => {
  21. const actual = await importOriginal<typeof import('node:fs/promises')>()
  22. return { ...actual, rm: vi.fn(actual.rm) }
  23. })
  24. /**
  25. * Unit tests for the shared out-of-process machinery. The env scrub and the
  26. * isolated-config-dir helpers run against the REAL process env and REAL
  27. * filesystem (one exception: the rm-failure path injects its rejection at the
  28. * mocked fs boundary, see above); the exit waits and the dispose ladder run
  29. * against a scriptable fake child so each escalation tier's timing is driven
  30. * deterministically (the ACP backend's suite exercises the same ladder
  31. * against real subprocesses end to end).
  32. */
  33. /** What fells a scripted {@link FakeChild}. */
  34. type LethalTrigger = 'eof' | NodeJS.Signals
  35. /** Per-scenario script for a {@link FakeChild}. */
  36. interface FakeChildScript {
  37. /**
  38. * The one trigger that makes the child exit (SIGKILL always does,
  39. * uncatchable, like a real process). Omitted: only SIGKILL fells it.
  40. */
  41. diesOn?: LethalTrigger
  42. /** Delay (ms) between the lethal trigger and the exit event. */
  43. delayMs?: number
  44. /** Complete the scripted exit inside the triggering call. */
  45. synchronousExit?: boolean
  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. const exit = (): void => {
  76. if (trigger === 'eof') this.exitCode = 0
  77. else this.signalCode = trigger
  78. this.emit('exit', this.exitCode, this.signalCode)
  79. }
  80. if (this.script.synchronousExit === true) exit()
  81. else setTimeout(exit, this.script.delayMs ?? 0)
  82. }
  83. }
  84. /** The helpers take a real ChildProcess; the fake carries the read surface. */
  85. function asChild(fake: FakeChild): ChildProcess {
  86. return fake as unknown as ChildProcess
  87. }
  88. describe('buildChildEnv', () => {
  89. it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
  90. process.env.DSH_PROC_TEST_API_KEY = 'leak'
  91. process.env.dsh_proc_test_secret = 'leak'
  92. process.env.DSH_PROC_TEST_TOKEN = 'leak'
  93. try {
  94. const env = buildChildEnv({})
  95. expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
  96. expect(env.dsh_proc_test_secret).toBeUndefined()
  97. expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
  98. } finally {
  99. delete process.env.DSH_PROC_TEST_API_KEY
  100. delete process.env.dsh_proc_test_secret
  101. delete process.env.DSH_PROC_TEST_TOKEN
  102. }
  103. })
  104. it('forwards normal ambient vars', () => {
  105. expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
  106. })
  107. it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
  108. process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
  109. try {
  110. const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
  111. // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
  112. expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
  113. } finally {
  114. delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
  115. }
  116. })
  117. it('an extra overrides the ambient value of a non-credential var', () => {
  118. process.env.DSH_PROC_TEST_PLAIN = 'ambient'
  119. try {
  120. expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
  121. } finally {
  122. delete process.env.DSH_PROC_TEST_PLAIN
  123. }
  124. })
  125. })
  126. describe('spawnFailure', () => {
  127. it('resolves (never rejects) with the first error event', async () => {
  128. const fake = new FakeChild()
  129. const failure = spawnFailure(asChild(fake))
  130. const err = new Error('spawn ENOENT')
  131. fake.emit('error', err)
  132. await expect(failure).resolves.toBe(err)
  133. })
  134. it('never settles for a child that spawns cleanly and exits', async () => {
  135. const fake = new FakeChild({ diesOn: 'SIGTERM' })
  136. const failure = spawnFailure(asChild(fake))
  137. fake.kill('SIGTERM')
  138. await new Promise<void>(resolve => fake.once('exit', () => { resolve() }))
  139. // A clean lifecycle emits `exit`, never `error` — the capture stays
  140. // pending forever, so a race against it is decided by the other arms.
  141. const settled = await Promise.race([
  142. failure.then(() => 'settled'),
  143. new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
  144. ])
  145. expect(settled).toBe('pending')
  146. })
  147. })
  148. describe('disposeChildProcess', () => {
  149. it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
  150. const fake = new FakeChild()
  151. fake.exitCode = 0
  152. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  153. expect(fake.stdinEnded).toBe(false)
  154. expect(fake.kills).toEqual([])
  155. })
  156. it('returns immediately for a child already dead by signal', async () => {
  157. const fake = new FakeChild()
  158. fake.signalCode = 'SIGKILL'
  159. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  160. expect(fake.stdinEnded).toBe(false)
  161. expect(fake.kills).toEqual([])
  162. })
  163. it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
  164. const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
  165. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  166. expect(fake.stdinEnded).toBe(true)
  167. expect(fake.kills).toEqual([])
  168. expect(fake.exitCode).toBe(0)
  169. })
  170. it('recognizes a child that exits synchronously on stdin EOF', async () => {
  171. const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
  172. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
  173. expect(fake.exitCode).toBe(0)
  174. expect(fake.listenerCount('exit')).toBe(0)
  175. })
  176. it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
  177. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  178. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  179. expect(fake.stdinEnded).toBe(true)
  180. expect(fake.kills).toEqual(['SIGTERM'])
  181. expect(fake.signalCode).toBe('SIGTERM')
  182. expect(fake.listenerCount('exit')).toBe(0)
  183. })
  184. it('recognizes a child that exits synchronously on SIGTERM', async () => {
  185. const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
  186. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  187. expect(fake.kills).toEqual(['SIGTERM'])
  188. expect(fake.signalCode).toBe('SIGTERM')
  189. expect(fake.listenerCount('exit')).toBe(0)
  190. })
  191. it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
  192. const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
  193. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  194. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  195. // Quiescence, not a request: at resolution the child has ACTUALLY exited
  196. // (the exit event landed, despite the scripted post-SIGKILL delay).
  197. expect(fake.signalCode).toBe('SIGKILL')
  198. })
  199. it('recognizes a child already gone when the final exit wait begins', async () => {
  200. const fake = new FakeChild({ synchronousExit: true })
  201. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
  202. expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
  203. expect(fake.signalCode).toBe('SIGKILL')
  204. })
  205. it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
  206. const fake = new FakeChild()
  207. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  208. fake.kills.push(signal)
  209. queueMicrotask(() => {
  210. if (marker === 'exitCode') fake.exitCode = 0
  211. else fake.signalCode = 'SIGTERM'
  212. })
  213. return true
  214. })
  215. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
  216. expect(fake.kills).toEqual(['SIGTERM'])
  217. })
  218. it('walks the ladder for a child spawned without a stdin pipe', async () => {
  219. const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
  220. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
  221. expect(fake.kills).toEqual(['SIGTERM'])
  222. })
  223. it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
  224. const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
  225. await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
  226. expect(fake.kills).toEqual(['SIGKILL'])
  227. expect(fake.signalCode).toBe('SIGKILL')
  228. })
  229. it('propagates a forced-termination error without waiting for the grace', async () => {
  230. const fake = new FakeChild()
  231. const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
  232. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  233. fake.kills.push(signal)
  234. fake.emit('error', failure)
  235. return false
  236. })
  237. await expect(disposeChildProcess(
  238. asChild(fake),
  239. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  240. 'win32',
  241. )).rejects.toBe(failure)
  242. expect(fake.kills).toEqual(['SIGKILL'])
  243. expect(fake.listenerCount('error')).toBe(0)
  244. expect(fake.listenerCount('exit')).toBe(0)
  245. })
  246. it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
  247. const fake = new FakeChild()
  248. const failure = new Error('invalid signal state')
  249. vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
  250. await expect(disposeChildProcess(
  251. asChild(fake),
  252. { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
  253. 'win32',
  254. )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
  255. expect(fake.listenerCount('error')).toBe(0)
  256. expect(fake.listenerCount('exit')).toBe(0)
  257. })
  258. it('bounds a refused forced termination that produces no error or exit', async () => {
  259. const fake = new FakeChild()
  260. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  261. fake.kills.push(signal)
  262. return false
  263. })
  264. await expect(disposeChildProcess(
  265. asChild(fake),
  266. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  267. 'win32',
  268. )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
  269. expect(fake.listenerCount('error')).toBe(0)
  270. expect(fake.listenerCount('exit')).toBe(0)
  271. })
  272. it('bounds an accepted forced termination that never reports exit', async () => {
  273. const fake = new FakeChild()
  274. vi.spyOn(fake, 'kill').mockImplementation((signal) => {
  275. fake.kills.push(signal)
  276. return true
  277. })
  278. await expect(disposeChildProcess(
  279. asChild(fake),
  280. { disposeEofGraceMs: 1, disposeGraceMs: 10 },
  281. 'win32',
  282. )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
  283. expect(fake.listenerCount('error')).toBe(0)
  284. expect(fake.listenerCount('exit')).toBe(0)
  285. })
  286. })
  287. describe('createIsolatedConfigDir', () => {
  288. it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
  289. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  290. try {
  291. expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
  292. const st = await stat(dir.path)
  293. expect(st.isDirectory()).toBe(true)
  294. // Windows reports synthetic POSIX mode bits; privacy comes from the
  295. // inherited directory ACL rather than chmod-compatible mode bits.
  296. if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
  297. } finally {
  298. await dir.remove()
  299. }
  300. })
  301. it('creates a distinct dir per call (per-run isolation)', async () => {
  302. const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  303. const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  304. try {
  305. expect(a.path).not.toBe(b.path)
  306. } finally {
  307. await a.remove()
  308. await b.remove()
  309. }
  310. })
  311. it('remove() deletes a fresh dir recursively and is idempotent', async () => {
  312. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
  313. await writeFile(join(dir.path, 'settings.json'), '{}')
  314. await dir.remove()
  315. expect(existsSync(dir.path)).toBe(false)
  316. // Second remove: nothing left to delete, still resolves.
  317. await expect(dir.remove()).resolves.toBeUndefined()
  318. })
  319. it('returns a pinned dir verbatim and NEVER removes it', async () => {
  320. const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
  321. try {
  322. const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
  323. expect(dir.path).toBe(pinned)
  324. await dir.remove()
  325. // The deployment owns a pinned dir's lifecycle — remove() must not touch it.
  326. expect(existsSync(pinned)).toBe(true)
  327. } finally {
  328. await rm(pinned, { recursive: true, force: true })
  329. }
  330. })
  331. it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
  332. const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
  333. const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
  334. expect(dir.path).toBe(missing)
  335. expect(existsSync(missing)).toBe(false)
  336. await dir.remove()
  337. expect(existsSync(missing)).toBe(false)
  338. })
  339. it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
  340. const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
  341. try {
  342. // The swallow contract is error-kind agnostic; EACCES stands in for the
  343. // family (EBUSY, a vanished mount, …) that best-effort must absorb.
  344. vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
  345. await expect(dir.remove()).resolves.toBeUndefined()
  346. // The injected rejection consumed the only rm call — nothing was deleted.
  347. expect(existsSync(dir.path)).toBe(true)
  348. } finally {
  349. await rm(dir.path, { recursive: true, force: true })
  350. }
  351. })
  352. })
  353. describe('NO_START_CAPABILITIES', () => {
  354. it('advertises nothing and is frozen (shared by every out-of-process backend)', () => {
  355. expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
  356. expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true)
  357. })
  358. })
  359. describe('settleRunResult', () => {
  360. const wiring = () => {
  361. const controller = new AbortController()
  362. const onAbort = vi.fn()
  363. controller.signal.addEventListener('abort', onAbort)
  364. return { controller, onAbort }
  365. }
  366. it('passes a successful attempt through and removes the abort listener', async () => {
  367. const { controller, onAbort } = wiring()
  368. const result = await settleRunResult({
  369. attempt: async () => ({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' }),
  370. collectOutput: () => [],
  371. cancelled: () => false,
  372. signal: controller.signal,
  373. onAbort,
  374. })
  375. expect(result.stopReason).toBe('completed')
  376. controller.abort()
  377. // The listener was removed at settlement, so the abort never reaches it.
  378. expect(onAbort).not.toHaveBeenCalled()
  379. })
  380. it('reads an in-flight rejection as aborted when cancellation already settled', async () => {
  381. const { controller, onAbort } = wiring()
  382. const result = await settleRunResult({
  383. attempt: async () => { throw new Error('pipe torn mid-cancel') },
  384. collectOutput: () => [{ type: 'text', text: 'partial' }],
  385. cancelled: () => true,
  386. signal: controller.signal,
  387. onAbort,
  388. })
  389. expect(result).toEqual({ output: [{ type: 'text', text: 'partial' }], stopReason: 'aborted' })
  390. })
  391. it('flattens a failure through a contained onError sink', async () => {
  392. const { controller, onAbort } = wiring()
  393. const seen: string[] = []
  394. const result = await settleRunResult({
  395. attempt: async () => { throw new Error('transport died') },
  396. collectOutput: () => [],
  397. cancelled: () => false,
  398. onError: (error, stopReason) => {
  399. seen.push(`${stopReason}:${error.message}`)
  400. throw new Error('sink failure must be contained')
  401. },
  402. signal: controller.signal,
  403. onAbort,
  404. })
  405. expect(result.stopReason).toBe('error')
  406. expect(seen).toEqual(['error:transport died'])
  407. })
  408. it('flattens a failure without a sink', async () => {
  409. const { controller, onAbort } = wiring()
  410. const result = await settleRunResult({
  411. attempt: async () => { throw new Error('no sink configured') },
  412. collectOutput: () => [],
  413. cancelled: () => false,
  414. signal: controller.signal,
  415. onAbort,
  416. })
  417. expect(result.stopReason).toBe('error')
  418. })
  419. })
  420. describe('subprocessRunHandle', () => {
  421. it('publishes an idempotent dispose that cancels locally and awaits teardown', async () => {
  422. const controller = new AbortController()
  423. const onAbort = vi.fn()
  424. controller.signal.addEventListener('abort', onAbort)
  425. const requestCancel = vi.fn()
  426. const teardown = vi.fn(() => Promise.resolve())
  427. const run = subprocessRunHandle({
  428. id: SessionId('run-1'),
  429. result: Promise.resolve({ output: [], stopReason: 'completed' }),
  430. signal: controller.signal,
  431. onAbort,
  432. requestCancel,
  433. teardown,
  434. })
  435. expect(run.localAgent).toBeUndefined()
  436. expect(String(run.id)).toBe('run-1')
  437. const disposal = run.dispose()
  438. expect(run.dispose()).toBe(disposal)
  439. await disposal
  440. expect(requestCancel).toHaveBeenCalledTimes(1)
  441. expect(teardown).toHaveBeenCalledTimes(1)
  442. controller.abort()
  443. // dispose removed the abort listener before cancelling.
  444. expect(onAbort).not.toHaveBeenCalled()
  445. })
  446. })