| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- import { describe, expect, it, vi } from 'vitest'
- import { EventEmitter } from 'node:events'
- import { existsSync } from 'node:fs'
- import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import type { ChildProcess } from 'node:child_process'
- import {
- buildChildEnv,
- createIsolatedConfigDir,
- disposeChildProcess,
- spawnFailure,
- } from '../src/index.ts'
- // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
- // failures are not portable and disappear under root, so this is the sanctioned filesystem seam.
- vi.mock('node:fs/promises', async (importOriginal) => {
- const actual = await importOriginal<typeof import('node:fs/promises')>()
- return { ...actual, rm: vi.fn(actual.rm) }
- })
- /**
- * Unit tests for the shared out-of-process machinery. The env scrub and the
- * isolated-config-dir helpers run against the REAL process env and REAL
- * filesystem (one exception: the rm-failure path injects its rejection at the
- * mocked fs boundary, see above); the exit waits and the dispose ladder run
- * against a scriptable fake child so each escalation tier's timing is driven
- * deterministically (the ACP backend's suite exercises the same ladder
- * against real subprocesses end to end).
- */
- /** What fells a scripted {@link FakeChild}. */
- type LethalTrigger = 'eof' | NodeJS.Signals
- /** Per-scenario script for a {@link FakeChild}. */
- interface FakeChildScript {
- /**
- * The one trigger that makes the child exit (SIGKILL always does,
- * uncatchable, like a real process). Omitted: only SIGKILL fells it.
- */
- diesOn?: LethalTrigger
- /** Delay (ms) between the lethal trigger and the exit event. */
- delayMs?: number
- /** Complete the scripted exit inside the triggering call. */
- synchronousExit?: boolean
- /** `false` models a child spawned without a stdin pipe. */
- stdin?: boolean
- }
- /**
- * A scriptable stand-in for a ChildProcess carrying exactly the surface the
- * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
- * `exit` event.
- */
- class FakeChild extends EventEmitter {
- exitCode: number | null = null
- signalCode: NodeJS.Signals | null = null
- readonly kills: NodeJS.Signals[] = []
- stdinEnded = false
- readonly stdin: { end: () => void } | null
- constructor(private readonly script: FakeChildScript = {}) {
- super()
- this.stdin = script.stdin === false
- ? null
- : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
- }
- kill(signal: NodeJS.Signals): boolean {
- this.kills.push(signal)
- this.maybeDie(signal)
- return true
- }
- private maybeDie(trigger: LethalTrigger): void {
- // SIGKILL is uncatchable — it always fells the child; any other trigger
- // only when the scenario scripts it as the lethal one.
- if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
- const exit = (): void => {
- if (trigger === 'eof') this.exitCode = 0
- else this.signalCode = trigger
- this.emit('exit', this.exitCode, this.signalCode)
- }
- if (this.script.synchronousExit === true) exit()
- else setTimeout(exit, this.script.delayMs ?? 0)
- }
- }
- /** The helpers take a real ChildProcess; the fake carries the read surface. */
- function asChild(fake: FakeChild): ChildProcess {
- return fake as unknown as ChildProcess
- }
- describe('buildChildEnv', () => {
- it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => {
- process.env.DSH_PROC_TEST_API_KEY = 'leak'
- process.env.dsh_proc_test_secret = 'leak'
- process.env.DSH_PROC_TEST_TOKEN = 'leak'
- try {
- const env = buildChildEnv({})
- expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined()
- expect(env.dsh_proc_test_secret).toBeUndefined()
- expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined()
- } finally {
- delete process.env.DSH_PROC_TEST_API_KEY
- delete process.env.dsh_proc_test_secret
- delete process.env.DSH_PROC_TEST_TOKEN
- }
- })
- it('forwards normal ambient vars', () => {
- expect(buildChildEnv({}).PATH).toBe(process.env.PATH)
- })
- it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => {
- process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak'
- try {
- const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' })
- // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child.
- expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit')
- } finally {
- delete process.env.DSH_PROC_TEST_EXTRA_TOKEN
- }
- })
- it('an extra overrides the ambient value of a non-credential var', () => {
- process.env.DSH_PROC_TEST_PLAIN = 'ambient'
- try {
- expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override')
- } finally {
- delete process.env.DSH_PROC_TEST_PLAIN
- }
- })
- })
- describe('spawnFailure', () => {
- it('resolves (never rejects) with the first error event', async () => {
- const fake = new FakeChild()
- const failure = spawnFailure(asChild(fake))
- const err = new Error('spawn ENOENT')
- fake.emit('error', err)
- await expect(failure).resolves.toBe(err)
- })
- it('never settles for a child that spawns cleanly and exits', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM' })
- const failure = spawnFailure(asChild(fake))
- fake.kill('SIGTERM')
- await new Promise<void>(resolve => fake.once('exit', () => { resolve() }))
- // A clean lifecycle emits `exit`, never `error` — the capture stays
- // pending forever, so a race against it is decided by the other arms.
- const settled = await Promise.race([
- failure.then(() => 'settled'),
- new Promise<string>(resolve => setTimeout(() => { resolve('pending') }, 30)),
- ])
- expect(settled).toBe('pending')
- })
- })
- describe('disposeChildProcess', () => {
- it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
- const fake = new FakeChild()
- fake.exitCode = 0
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
- it('returns immediately for a child already dead by signal', async () => {
- const fake = new FakeChild()
- fake.signalCode = 'SIGKILL'
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
- it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
- const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual([])
- expect(fake.exitCode).toBe(0)
- })
- it('recognizes a child that exits synchronously on stdin EOF', async () => {
- const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.exitCode).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('recognizes a child that exits synchronously on SIGTERM', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
- const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- // Quiescence, not a request: at resolution the child has ACTUALLY exited
- // (the exit event landed, despite the scripted post-SIGKILL delay).
- expect(fake.signalCode).toBe('SIGKILL')
- })
- it('recognizes a child already gone when the final exit wait begins', async () => {
- const fake = new FakeChild({ synchronousExit: true })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- expect(fake.signalCode).toBe('SIGKILL')
- })
- it('walks the ladder for a child spawned without a stdin pipe', async () => {
- const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
- await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
- expect(fake.kills).toEqual(['SIGTERM'])
- })
- })
- describe('createIsolatedConfigDir', () => {
- it('creates a fresh private mkdtemp dir under the OS temp root', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- try {
- expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
- const st = await stat(dir.path)
- expect(st.isDirectory()).toBe(true)
- // Private (0700) per the defensive-patterns temp-dir rule.
- expect(st.mode & 0o777).toBe(0o700)
- } finally {
- await dir.remove()
- }
- })
- it('creates a distinct dir per call (per-run isolation)', async () => {
- const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- try {
- expect(a.path).not.toBe(b.path)
- } finally {
- await a.remove()
- await b.remove()
- }
- })
- it('remove() deletes a fresh dir recursively and is idempotent', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-')
- await writeFile(join(dir.path, 'settings.json'), '{}')
- await dir.remove()
- expect(existsSync(dir.path)).toBe(false)
- // Second remove: nothing left to delete, still resolves.
- await expect(dir.remove()).resolves.toBeUndefined()
- })
- it('returns a pinned dir verbatim and NEVER removes it', async () => {
- const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-'))
- try {
- const dir = await createIsolatedConfigDir('ignored-prefix-', pinned)
- expect(dir.path).toBe(pinned)
- await dir.remove()
- // The deployment owns a pinned dir's lifecycle — remove() must not touch it.
- expect(existsSync(pinned)).toBe(true)
- } finally {
- await rm(pinned, { recursive: true, force: true })
- }
- })
- it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => {
- const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`)
- const dir = await createIsolatedConfigDir('ignored-prefix-', missing)
- expect(dir.path).toBe(missing)
- expect(existsSync(missing)).toBe(false)
- await dir.remove()
- expect(existsSync(missing)).toBe(false)
- })
- it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => {
- const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-')
- try {
- // The swallow contract is error-kind agnostic; EACCES stands in for the
- // family (EBUSY, a vanished mount, …) that best-effort must absorb.
- vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }))
- await expect(dir.remove()).resolves.toBeUndefined()
- // The injected rejection consumed the only rm call — nothing was deleted.
- expect(existsSync(dir.path)).toBe(true)
- } finally {
- await rm(dir.path, { recursive: true, force: true })
- }
- })
- })
|