run.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import { mkdtempSync, readFileSync, statSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
  6. import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
  7. const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
  8. vi.mock('node:fs', async (importOriginal) => {
  9. const actual = await importOriginal<typeof import('node:fs')>()
  10. return {
  11. ...actual,
  12. closeSync(fd: number): void {
  13. if (failNextClose.value) {
  14. failNextClose.value = false
  15. throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
  16. }
  17. actual.closeSync(fd)
  18. },
  19. }
  20. })
  21. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
  22. function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
  23. return {
  24. command,
  25. cwd: process.cwd(),
  26. maxOutputBytes: 64_000,
  27. graceMs: 3_000,
  28. ...overrides,
  29. }
  30. }
  31. /** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
  32. async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
  33. const deadline = Date.now() + timeoutMs
  34. while (Date.now() < deadline) {
  35. try {
  36. process.kill(pid, 0)
  37. } catch {
  38. return
  39. }
  40. await new Promise(resolve => setTimeout(resolve, 20))
  41. }
  42. throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
  43. }
  44. async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
  45. const deadline = Date.now() + timeoutMs
  46. while (Date.now() < deadline) {
  47. if (running.stdout.snapshot().text.includes(expected)) return
  48. await new Promise(resolve => setTimeout(resolve, 20))
  49. }
  50. throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
  51. }
  52. async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
  53. const deadline = Date.now() + timeoutMs
  54. while (Date.now() < deadline) {
  55. try {
  56. const pid = Number(readFileSync(path, 'utf8').trim())
  57. if (Number.isSafeInteger(pid) && pid > 0) return pid
  58. } catch {
  59. // The child shell has not written the pid file yet.
  60. }
  61. await new Promise(resolve => setTimeout(resolve, 20))
  62. }
  63. throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
  64. }
  65. describe('runBash', () => {
  66. it('captures stdout on success', async () => {
  67. const result = await runBash(spec('echo hello')).done
  68. expect(result.exitCode).toBe(0)
  69. expect(result.signal).toBeNull()
  70. expect(result.stdout.text).toBe('hello\n')
  71. expect(result.stdout.truncated).toBe(false)
  72. expect(result.stderr.text).toBe('')
  73. })
  74. it('captures stderr separately', async () => {
  75. const result = await runBash(spec('echo oops >&2')).done
  76. expect(result.exitCode).toBe(0)
  77. expect(result.stdout.text).toBe('')
  78. expect(result.stderr.text).toBe('oops\n')
  79. })
  80. it('captures both streams', async () => {
  81. const result = await runBash(spec('echo out; echo err >&2')).done
  82. expect(result.stdout.text).toBe('out\n')
  83. expect(result.stderr.text).toBe('err\n')
  84. })
  85. it('reports non-zero exit codes', async () => {
  86. const result = await runBash(spec('exit 42')).done
  87. expect(result.exitCode).toBe(42)
  88. expect(result.signal).toBeNull()
  89. })
  90. it('applies model-friendly env overrides', async () => {
  91. const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
  92. expect(result.stdout.text).toBe('1/dumb/cat\n')
  93. })
  94. it('runs in the requested cwd', async () => {
  95. const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
  96. expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
  97. })
  98. it('kills the process group with SIGTERM when the signal fires', async () => {
  99. // runBash owns no timer: it kills on abort. The executor drives the timeout
  100. // by firing this signal via a deadline (see executor.spec.ts); here we
  101. // assert the kill itself lands as SIGTERM.
  102. const controller = new AbortController()
  103. const start = Date.now()
  104. const running = runBash(spec('sleep 60', { signal: controller.signal }))
  105. setTimeout(() => { controller.abort('deadline') }, 100)
  106. const result = await running.done
  107. expect(Date.now() - start).toBeLessThan(5_000)
  108. expect(result.signal).toBe('SIGTERM')
  109. expect(result.exitCode).toBeNull()
  110. })
  111. it('escalates to SIGKILL when SIGTERM is trapped', async () => {
  112. const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
  113. await waitForStdout(running, 'ready\n')
  114. running.kill()
  115. const result = await running.done
  116. expect(result.signal).toBe('SIGKILL')
  117. })
  118. it('kills the whole process group (grandchildren die too)', async () => {
  119. // The subshell writes the sleep's pid then waits on it; killing the
  120. // group must take the sleep down with bash.
  121. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
  122. const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
  123. const grandchild = await waitForPidFile(pidFile)
  124. expect(grandchild).toBeGreaterThan(0)
  125. running.kill()
  126. const result = await running.done
  127. expect(result.signal).toBe('SIGTERM')
  128. await waitGone(grandchild)
  129. })
  130. it('aborts via AbortSignal mid-run', async () => {
  131. const controller = new AbortController()
  132. const running = runBash(spec('sleep 60', { signal: controller.signal }))
  133. setTimeout(() => { controller.abort('user cancelled') }, 50)
  134. const result = await running.done
  135. expect(result.signal).toBe('SIGTERM')
  136. })
  137. it('throws when the signal is already aborted before spawn', () => {
  138. const controller = new AbortController()
  139. controller.abort('too late')
  140. expect(() => runBash(spec('echo hi', { signal: controller.signal })))
  141. .toThrow(/aborted before spawn: too late/)
  142. })
  143. it('rejects with a spawn error for a nonexistent cwd', async () => {
  144. await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
  145. .rejects.toThrow(/ENOENT/)
  146. })
  147. it('kill() is idempotent (second call does not restart escalation)', async () => {
  148. const running = runBash(spec('sleep 60'))
  149. running.kill()
  150. running.kill()
  151. const result = await running.done
  152. expect(result.signal).toBe('SIGTERM')
  153. })
  154. })
  155. describe('stdin and extra env (set by in-process plugins)', () => {
  156. it('writes stdin to the command and closes it', async () => {
  157. const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
  158. expect(result.exitCode).toBe(0)
  159. expect(result.stdout.text).toBe('hello from stdin\n')
  160. })
  161. it('a command that reads stdin sees EOF when none is supplied', async () => {
  162. // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
  163. // output (it does NOT block).
  164. const result = await runBash(spec('cat')).done
  165. expect(result.exitCode).toBe(0)
  166. expect(result.stdout.text).toBe('')
  167. })
  168. it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
  169. // The no-stdin path must stay observationally identical to the pre-seam
  170. // `ignore` default: a command that probes stdin's file type sees a char
  171. // device (/dev/null). Regressing to an always-open pipe would make fd 0 a
  172. // socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
  173. // `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
  174. // fd 0 is that pipe (a socket), as it must be to carry them.
  175. const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
  176. expect(none.stdout.text).toBe('char\n')
  177. const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
  178. expect(piped.stdout.text).toBe('socket\n')
  179. })
  180. it('merges extra env entries onto the scrubbed environment', async () => {
  181. const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
  182. env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
  183. })).done
  184. expect(result.stdout.text).toBe('alpha/beta\n')
  185. })
  186. it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
  187. // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
  188. // DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
  189. // entry is still honored — the scrub only drops AMBIENT process.env creds.
  190. const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
  191. env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
  192. })).done
  193. expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
  194. })
  195. it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
  196. // The child exits immediately without reading; closing our end of a stdin
  197. // pipe still holding ~1MiB triggers EPIPE on the write. The handler must
  198. // swallow it: `done` resolves normally with the child's real exit.
  199. const big = 'x'.repeat(1024 * 1024)
  200. const result = await runBash(spec('exit 7', { stdin: big })).done
  201. expect(result.exitCode).toBe(7)
  202. })
  203. })
  204. describe('output truncation and spill', () => {
  205. it('keeps the tail and spills the full stream to disk', async () => {
  206. // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
  207. const result = await runBash(
  208. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  209. { spillDir },
  210. ).done
  211. expect(result.stdout.truncated).toBe(true)
  212. expect(result.stdout.text.length).toBeLessThanOrEqual(500)
  213. expect(result.stdout.text).toContain('line-0200')
  214. expect(result.stdout.text).not.toContain('line-0001')
  215. expect(result.stdout.spillPath).toBeDefined()
  216. const full = readFileSync(result.stdout.spillPath!, 'utf8')
  217. expect(full).toContain('line-0001')
  218. expect(full).toContain('line-0200')
  219. })
  220. it('does not truncate output exactly at the cap', async () => {
  221. const result = await runBash(
  222. spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
  223. { spillDir },
  224. ).done
  225. expect(result.stdout.truncated).toBe(false)
  226. expect(result.stdout.text.length).toBe(500)
  227. expect(result.stdout.spillPath).toBeUndefined()
  228. })
  229. it('settles with the tail and no spill path when final spill close fails', async () => {
  230. failNextClose.value = true
  231. const result = await runBash(
  232. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  233. { spillDir },
  234. ).done
  235. expect(failNextClose.value).toBe(false)
  236. expect(result.exitCode).toBe(0)
  237. expect(result.stdout.truncated).toBe(true)
  238. expect(result.stdout.text).toContain('line-0200')
  239. expect(result.stdout.spillPath).toBeUndefined()
  240. })
  241. })
  242. describe('OutputCollector', () => {
  243. it('keeps the tail of a single oversized chunk', () => {
  244. const collector = new OutputCollector(10, 'test', spillDir)
  245. collector.push(Buffer.from('0123456789abcdef'))
  246. const out = collector.finalize()
  247. expect(out.text).toBe('6789abcdef')
  248. expect(out.truncated).toBe(true)
  249. expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
  250. })
  251. it('readFrom returns increments and flags lossy reads', () => {
  252. const collector = new OutputCollector(10, 'test', spillDir)
  253. collector.push(Buffer.from('aaaaa'))
  254. const first = collector.readFrom(0)
  255. expect(first.text).toBe('aaaaa')
  256. expect(first.lossy).toBe(false)
  257. expect(first.nextOffset).toBe(5)
  258. collector.push(Buffer.from('bbbbb'))
  259. const second = collector.readFrom(first.nextOffset)
  260. expect(second.text).toBe('bbbbb')
  261. expect(second.lossy).toBe(false)
  262. // Push enough to slide the window past the last offset.
  263. collector.push(Buffer.from('c'.repeat(20)))
  264. const third = collector.readFrom(second.nextOffset)
  265. expect(third.lossy).toBe(true)
  266. expect(third.text).toBe('c'.repeat(10))
  267. expect(third.spillPath).toBeDefined()
  268. })
  269. it('tracks totalBytes across drops', () => {
  270. const collector = new OutputCollector(4, 'test', spillDir)
  271. collector.push(Buffer.from('aaaa'))
  272. collector.push(Buffer.from('bbbb'))
  273. expect(collector.totalBytes).toBe(8)
  274. expect(collector.finalize().text).toBe('bbbb')
  275. })
  276. it('contains close failures and drops the spill path', () => {
  277. const collector = new OutputCollector(4, 'closefail', spillDir)
  278. collector.push(Buffer.from('aaaa'))
  279. collector.push(Buffer.from('bbbb'))
  280. expect(collector.snapshot().spillPath).toBeDefined()
  281. failNextClose.value = true
  282. let out: ReturnType<typeof collector.finalize>
  283. expect(() => { out = collector.finalize() }).not.toThrow()
  284. expect(failNextClose.value).toBe(false)
  285. expect(out!.text).toBe('bbbb')
  286. expect(out!.truncated).toBe(true)
  287. expect(out!.spillPath).toBeUndefined()
  288. })
  289. })
  290. describe('killGroup', () => {
  291. it('ignores non-positive pids', () => {
  292. expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
  293. expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
  294. })
  295. it('swallows ESRCH for vanished groups', async () => {
  296. const running = runBash(spec('true'))
  297. await running.done
  298. expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
  299. })
  300. })
  301. describe('abort edge cases', () => {
  302. it('reports a fallback reason for reason-less pre-aborted signals', () => {
  303. // Real AbortControllers always set a DOMException reason; signal-like
  304. // objects from other libraries may not — the fallback covers them.
  305. const bare = {
  306. aborted: true,
  307. reason: undefined,
  308. addEventListener() {},
  309. removeEventListener() {},
  310. } as unknown as AbortSignal
  311. expect(() => runBash(spec('echo hi', { signal: bare })))
  312. .toThrow(/aborted before spawn: aborted/)
  313. })
  314. it('reports the terminating signal of an externally self-killed command', async () => {
  315. // runBash reports the raw signal; whether it counts as timeout/cancel is the
  316. // executor's classification (a self-kill is neither) — see executor.spec.ts.
  317. const result = await runBash(spec('kill -TERM $$')).done
  318. expect(result.signal).toBe('SIGTERM')
  319. })
  320. })
  321. describe('review fixes: env scrubbing and spill hardening', () => {
  322. it('scrubs credential-shaped env vars from child processes', async () => {
  323. process.env.DSH_TEST_API_KEY = 'super-secret'
  324. process.env.DSH_TEST_TOKEN = 'also-secret'
  325. process.env.DSH_TEST_PLAIN = 'visible'
  326. try {
  327. const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
  328. expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
  329. } finally {
  330. delete process.env.DSH_TEST_API_KEY
  331. delete process.env.DSH_TEST_TOKEN
  332. delete process.env.DSH_TEST_PLAIN
  333. }
  334. })
  335. it('creates spill files with owner-only permissions and random names', async () => {
  336. const result = await runBash(
  337. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  338. { spillDir },
  339. ).done
  340. const path = result.stdout.spillPath!
  341. expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
  342. const mode = statSync(path).mode & 0o777
  343. expect(mode).toBe(0o600)
  344. })
  345. it('defaults spills into a private per-process directory', async () => {
  346. const result = await runBash(
  347. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  348. ).done
  349. const dir = dirname(result.stdout.spillPath!)
  350. expect(dir).toMatch(/dsh-bash-/)
  351. const mode = statSync(dir).mode & 0o777
  352. expect(mode).toBe(0o700)
  353. })
  354. it('killGroup never throws, even for EPERM-style failures', () => {
  355. const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
  356. throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
  357. })
  358. try {
  359. expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
  360. } finally {
  361. spy.mockRestore()
  362. }
  363. })
  364. it('honors AbortSignal on background-style runs (no timeout)', async () => {
  365. const controller = new AbortController()
  366. const running = runBash(spec('sleep 60', { signal: controller.signal }))
  367. setTimeout(() => { controller.abort() }, 50)
  368. const result = await running.done
  369. expect(result.signal).toBe('SIGTERM')
  370. })
  371. })