run.spec.ts 17 KB

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