run.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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. const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
  7. vi.mock('node:fs', async (importOriginal) => {
  8. const actual = await importOriginal<typeof import('node:fs')>()
  9. return {
  10. ...actual,
  11. closeSync(fd: number): void {
  12. if (failNextClose.value) {
  13. failNextClose.value = false
  14. throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
  15. }
  16. actual.closeSync(fd)
  17. },
  18. }
  19. })
  20. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
  21. function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
  22. return {
  23. command,
  24. cwd: process.cwd(),
  25. timeoutMs: 0,
  26. maxOutputBytes: 64_000,
  27. ...overrides,
  28. }
  29. }
  30. /** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
  31. async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
  32. const deadline = Date.now() + timeoutMs
  33. while (Date.now() < deadline) {
  34. try {
  35. process.kill(pid, 0)
  36. } catch {
  37. return
  38. }
  39. await new Promise(resolve => setTimeout(resolve, 20))
  40. }
  41. throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
  42. }
  43. describe('runBash', () => {
  44. it('captures stdout on success', async () => {
  45. const result = await runBash(spec('echo hello')).done
  46. expect(result.exitCode).toBe(0)
  47. expect(result.signal).toBeNull()
  48. expect(result.timedOut).toBe(false)
  49. expect(result.aborted).toBe(false)
  50. expect(result.stdout.text).toBe('hello\n')
  51. expect(result.stdout.truncated).toBe(false)
  52. expect(result.stderr.text).toBe('')
  53. })
  54. it('captures stderr separately', async () => {
  55. const result = await runBash(spec('echo oops >&2')).done
  56. expect(result.exitCode).toBe(0)
  57. expect(result.stdout.text).toBe('')
  58. expect(result.stderr.text).toBe('oops\n')
  59. })
  60. it('captures both streams', async () => {
  61. const result = await runBash(spec('echo out; echo err >&2')).done
  62. expect(result.stdout.text).toBe('out\n')
  63. expect(result.stderr.text).toBe('err\n')
  64. })
  65. it('reports non-zero exit codes', async () => {
  66. const result = await runBash(spec('exit 42')).done
  67. expect(result.exitCode).toBe(42)
  68. expect(result.signal).toBeNull()
  69. })
  70. it('applies model-friendly env overrides', async () => {
  71. const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
  72. expect(result.stdout.text).toBe('1/dumb/cat\n')
  73. })
  74. it('runs in the requested cwd', async () => {
  75. const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
  76. expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
  77. })
  78. it('kills with SIGTERM on timeout', async () => {
  79. const start = Date.now()
  80. const result = await runBash(spec('sleep 60', { timeoutMs: 100 })).done
  81. expect(Date.now() - start).toBeLessThan(5_000)
  82. expect(result.timedOut).toBe(true)
  83. expect(result.signal).toBe('SIGTERM')
  84. expect(result.exitCode).toBeNull()
  85. })
  86. it('escalates to SIGKILL when SIGTERM is trapped', async () => {
  87. const result = await runBash(
  88. spec('trap \'\' TERM; sleep 60', { timeoutMs: 100 }),
  89. { graceMs: 200 },
  90. ).done
  91. expect(result.timedOut).toBe(true)
  92. expect(result.signal).toBe('SIGKILL')
  93. })
  94. it('kills the whole process group (grandchildren die too)', async () => {
  95. // The subshell writes the sleep's pid then waits on it; killing the
  96. // group must take the sleep down with bash.
  97. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
  98. const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
  99. await new Promise(resolve => setTimeout(resolve, 300))
  100. const grandchild = Number(readFileSync(pidFile, 'utf8').trim())
  101. expect(grandchild).toBeGreaterThan(0)
  102. running.kill()
  103. const result = await running.done
  104. expect(result.signal).toBe('SIGTERM')
  105. await waitGone(grandchild)
  106. })
  107. it('aborts via AbortSignal mid-run', async () => {
  108. const controller = new AbortController()
  109. const running = runBash(spec('sleep 60', { signal: controller.signal }))
  110. setTimeout(() => { controller.abort('user cancelled') }, 50)
  111. const result = await running.done
  112. expect(result.aborted).toBe(true)
  113. expect(result.signal).toBe('SIGTERM')
  114. })
  115. it('throws when the signal is already aborted before spawn', () => {
  116. const controller = new AbortController()
  117. controller.abort('too late')
  118. expect(() => runBash(spec('echo hi', { signal: controller.signal })))
  119. .toThrow(/aborted before spawn: too late/)
  120. })
  121. it('rejects with a spawn error for a nonexistent cwd', async () => {
  122. await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
  123. .rejects.toThrow(/ENOENT/)
  124. })
  125. it('kill() is idempotent (second call does not restart escalation)', async () => {
  126. const running = runBash(spec('sleep 60'))
  127. running.kill()
  128. running.kill()
  129. const result = await running.done
  130. expect(result.signal).toBe('SIGTERM')
  131. })
  132. })
  133. describe('output truncation and spill', () => {
  134. it('keeps the tail and spills the full stream to disk', async () => {
  135. // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
  136. const result = await runBash(
  137. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  138. { spillDir },
  139. ).done
  140. expect(result.stdout.truncated).toBe(true)
  141. expect(result.stdout.text.length).toBeLessThanOrEqual(500)
  142. expect(result.stdout.text).toContain('line-0200')
  143. expect(result.stdout.text).not.toContain('line-0001')
  144. expect(result.stdout.spillPath).toBeDefined()
  145. const full = readFileSync(result.stdout.spillPath!, 'utf8')
  146. expect(full).toContain('line-0001')
  147. expect(full).toContain('line-0200')
  148. })
  149. it('does not truncate output exactly at the cap', async () => {
  150. const result = await runBash(
  151. spec('printf "%.0sx" $(seq 1 500)', { maxOutputBytes: 500 }),
  152. { spillDir },
  153. ).done
  154. expect(result.stdout.truncated).toBe(false)
  155. expect(result.stdout.text.length).toBe(500)
  156. expect(result.stdout.spillPath).toBeUndefined()
  157. })
  158. it('settles with the tail and no spill path when final spill close fails', async () => {
  159. failNextClose.value = true
  160. const result = await runBash(
  161. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  162. { spillDir },
  163. ).done
  164. expect(failNextClose.value).toBe(false)
  165. expect(result.exitCode).toBe(0)
  166. expect(result.stdout.truncated).toBe(true)
  167. expect(result.stdout.text).toContain('line-0200')
  168. expect(result.stdout.spillPath).toBeUndefined()
  169. })
  170. })
  171. describe('OutputCollector', () => {
  172. it('keeps the tail of a single oversized chunk', () => {
  173. const collector = new OutputCollector(10, 'test', spillDir)
  174. collector.push(Buffer.from('0123456789abcdef'))
  175. const out = collector.finalize()
  176. expect(out.text).toBe('6789abcdef')
  177. expect(out.truncated).toBe(true)
  178. expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
  179. })
  180. it('readFrom returns increments and flags lossy reads', () => {
  181. const collector = new OutputCollector(10, 'test', spillDir)
  182. collector.push(Buffer.from('aaaaa'))
  183. const first = collector.readFrom(0)
  184. expect(first.text).toBe('aaaaa')
  185. expect(first.lossy).toBe(false)
  186. expect(first.nextOffset).toBe(5)
  187. collector.push(Buffer.from('bbbbb'))
  188. const second = collector.readFrom(first.nextOffset)
  189. expect(second.text).toBe('bbbbb')
  190. expect(second.lossy).toBe(false)
  191. // Push enough to slide the window past the last offset.
  192. collector.push(Buffer.from('c'.repeat(20)))
  193. const third = collector.readFrom(second.nextOffset)
  194. expect(third.lossy).toBe(true)
  195. expect(third.text).toBe('c'.repeat(10))
  196. expect(third.spillPath).toBeDefined()
  197. })
  198. it('tracks totalBytes across drops', () => {
  199. const collector = new OutputCollector(4, 'test', spillDir)
  200. collector.push(Buffer.from('aaaa'))
  201. collector.push(Buffer.from('bbbb'))
  202. expect(collector.totalBytes).toBe(8)
  203. expect(collector.finalize().text).toBe('bbbb')
  204. })
  205. it('contains close failures and drops the spill path', () => {
  206. const collector = new OutputCollector(4, 'closefail', spillDir)
  207. collector.push(Buffer.from('aaaa'))
  208. collector.push(Buffer.from('bbbb'))
  209. expect(collector.snapshot().spillPath).toBeDefined()
  210. failNextClose.value = true
  211. let out: ReturnType<typeof collector.finalize>
  212. expect(() => { out = collector.finalize() }).not.toThrow()
  213. expect(failNextClose.value).toBe(false)
  214. expect(out!.text).toBe('bbbb')
  215. expect(out!.truncated).toBe(true)
  216. expect(out!.spillPath).toBeUndefined()
  217. })
  218. })
  219. describe('killGroup', () => {
  220. it('ignores non-positive pids', () => {
  221. expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
  222. expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
  223. })
  224. it('swallows ESRCH for vanished groups', async () => {
  225. const running = runBash(spec('true'))
  226. await running.done
  227. expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
  228. })
  229. })
  230. describe('abort edge cases', () => {
  231. it('reports a fallback reason for reason-less pre-aborted signals', () => {
  232. // Real AbortControllers always set a DOMException reason; signal-like
  233. // objects from other libraries may not — the fallback covers them.
  234. const bare = {
  235. aborted: true,
  236. reason: undefined,
  237. addEventListener() {},
  238. removeEventListener() {},
  239. } as unknown as AbortSignal
  240. expect(() => runBash(spec('echo hi', { signal: bare })))
  241. .toThrow(/aborted before spawn: aborted/)
  242. })
  243. it('reports an externally self-killed command without the timeout marker', async () => {
  244. const result = await runBash(spec('kill -TERM $$')).done
  245. expect(result.signal).toBe('SIGTERM')
  246. expect(result.timedOut).toBe(false)
  247. expect(result.aborted).toBe(false)
  248. })
  249. })
  250. describe('review fixes: env scrubbing and spill hardening', () => {
  251. it('scrubs credential-shaped env vars from child processes', async () => {
  252. process.env.DSH_TEST_API_KEY = 'super-secret'
  253. process.env.DSH_TEST_TOKEN = 'also-secret'
  254. process.env.DSH_TEST_PLAIN = 'visible'
  255. try {
  256. const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
  257. expect(result.stdout.text.trim()).toBe('[absent|absent|visible]')
  258. } finally {
  259. delete process.env.DSH_TEST_API_KEY
  260. delete process.env.DSH_TEST_TOKEN
  261. delete process.env.DSH_TEST_PLAIN
  262. }
  263. })
  264. it('creates spill files with owner-only permissions and random names', async () => {
  265. const result = await runBash(
  266. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  267. { spillDir },
  268. ).done
  269. const path = result.stdout.spillPath!
  270. expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
  271. const mode = statSync(path).mode & 0o777
  272. expect(mode).toBe(0o600)
  273. })
  274. it('defaults spills into a private per-process directory', async () => {
  275. const result = await runBash(
  276. spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
  277. ).done
  278. const dir = dirname(result.stdout.spillPath!)
  279. expect(dir).toMatch(/dsh-bash-/)
  280. const mode = statSync(dir).mode & 0o777
  281. expect(mode).toBe(0o700)
  282. })
  283. it('killGroup never throws, even for EPERM-style failures', () => {
  284. const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
  285. throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
  286. })
  287. try {
  288. expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
  289. } finally {
  290. spy.mockRestore()
  291. }
  292. })
  293. it('honors AbortSignal on background-style runs (no timeout)', async () => {
  294. const controller = new AbortController()
  295. const running = runBash(spec('sleep 60', { timeoutMs: 0, signal: controller.signal }))
  296. setTimeout(() => { controller.abort() }, 50)
  297. const result = await running.done
  298. expect(result.aborted).toBe(true)
  299. expect(result.signal).toBe('SIGTERM')
  300. })
  301. })