terminal.spec.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942
  1. import { Buffer } from 'node:buffer'
  2. import { once } from 'node:events'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import {
  6. CommandExitError,
  7. FileNotFoundError,
  8. SandboxNotFoundError,
  9. type CommandHandle,
  10. type CommandResult,
  11. type Sandbox,
  12. } from '@deepseek-ai/dsh-e2b'
  13. import type E2BRuntime from '@deepseek-ai/dsh-e2b'
  14. import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  15. import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
  16. import { spawnE2BTerminal } from '../src/terminal.ts'
  17. function commandError(exitCode: number): CommandExitError {
  18. return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
  19. }
  20. interface CommandOptions {
  21. signal?: AbortSignal
  22. cwd?: string
  23. envs?: Record<string, string>
  24. }
  25. class FakeTerminalCommandHandle {
  26. pid = 123
  27. disconnects = 0
  28. sdkKills = 0
  29. disconnectError: unknown
  30. sdkKillError: unknown
  31. waitError: unknown
  32. settleOnSdkKill = true
  33. private readonly result = Promise.withResolvers<CommandResult>()
  34. private settled = false
  35. wait(): Promise<CommandResult> {
  36. if (this.waitError !== undefined) throw this.waitError
  37. return this.result.promise
  38. }
  39. async disconnect(): Promise<void> {
  40. this.disconnects += 1
  41. if (this.disconnectError !== undefined) throw this.disconnectError
  42. }
  43. async kill(): Promise<boolean> {
  44. this.sdkKills += 1
  45. if (this.sdkKillError !== undefined) {
  46. const error = this.sdkKillError
  47. if (this.settleOnSdkKill) this.fail(137)
  48. throw error
  49. }
  50. if (this.settleOnSdkKill) this.fail(137)
  51. return true
  52. }
  53. succeed(exitCode = 0): void {
  54. if (this.settled) return
  55. this.settled = true
  56. this.result.resolve({ exitCode, stdout: '', stderr: '' })
  57. }
  58. fail(exitCode: number): void {
  59. if (this.settled) return
  60. this.settled = true
  61. this.result.reject(commandError(exitCode))
  62. }
  63. crash(error: unknown): void {
  64. if (this.settled) return
  65. this.settled = true
  66. this.result.reject(error)
  67. }
  68. asHandle(): CommandHandle {
  69. return this as unknown as CommandHandle
  70. }
  71. }
  72. class FakeTerminalSandbox {
  73. readonly handle = new FakeTerminalCommandHandle()
  74. readonly commands: string[] = []
  75. readonly commandOptions: CommandOptions[] = []
  76. readonly inputs: Array<{ pid: number; data: Buffer }> = []
  77. readonly removed: string[] = []
  78. readonly directories: string[] = []
  79. readonly writes = new Map<string, string>()
  80. createOptions: Parameters<Sandbox['pty']['create']>[0] | undefined
  81. ambient = 'KEEP=visible\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
  82. sessionId = '123\n'
  83. foreground = '456\n'
  84. groups = [123]
  85. zombieGroups: number[] = []
  86. createError: unknown
  87. writeError: unknown
  88. sendError: unknown
  89. commandFailure: unknown
  90. makeDirRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  91. sendInputRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  92. foregroundRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  93. signalRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
  94. sessionGroupsFailure: unknown
  95. foregroundFailure: unknown
  96. termFailure: unknown
  97. removeError: unknown
  98. clearOnTerm = true
  99. clearOnKill = true
  100. resolvedExecutable = '/usr/bin/node\n'
  101. requestedOutput = 'requested-shell$ '
  102. emitOutputMarker = true
  103. afterSessionLookup: (() => void) | undefined
  104. private createGate: Promise<undefined> | undefined
  105. private releaseCreateGate: (() => void) | undefined
  106. deferCreate(): void {
  107. const gate = Promise.withResolvers<undefined>()
  108. this.createGate = gate.promise
  109. this.releaseCreateGate = () => { gate.resolve(undefined) }
  110. }
  111. releaseCreate(): void {
  112. this.releaseCreateGate?.()
  113. }
  114. readonly sandbox = {
  115. files: {
  116. makeDir: async (path: string, options?: CommandOptions): Promise<boolean> => {
  117. this.directories.push(path)
  118. await this.makeDirRequest?.(options?.signal)
  119. options?.signal?.throwIfAborted()
  120. return true
  121. },
  122. write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
  123. for (const file of files) this.writes.set(file.path, file.data)
  124. if (this.writeError !== undefined) throw this.writeError
  125. return files.map(() => ({}))
  126. },
  127. remove: async (path: string): Promise<void> => {
  128. this.removed.push(path)
  129. if (this.removeError !== undefined) throw this.removeError
  130. },
  131. },
  132. commands: {
  133. run: async (command: string, options?: CommandOptions): Promise<CommandResult> => {
  134. this.commands.push(command)
  135. if (options !== undefined) this.commandOptions.push(options)
  136. options?.signal?.throwIfAborted()
  137. if (this.commandFailure !== undefined) {
  138. const error = this.commandFailure
  139. this.commandFailure = undefined
  140. throw error
  141. }
  142. if (command.includes('env -0 | base64')) {
  143. return {
  144. exitCode: 0,
  145. stdout: ['/home/user', this.ambient].map(value => Buffer.from(value).toString('base64')).join('\n'),
  146. stderr: '',
  147. }
  148. }
  149. if (command.includes('command -v -- ')) {
  150. return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
  151. }
  152. if (command.startsWith('ps -o sid=')) {
  153. this.afterSessionLookup?.()
  154. return { exitCode: 0, stdout: this.sessionId, stderr: '' }
  155. }
  156. if (command.startsWith('ps -o tpgid=')) {
  157. await this.foregroundRequest?.(options?.signal)
  158. options?.signal?.throwIfAborted()
  159. if (this.foregroundFailure !== undefined) throw this.foregroundFailure
  160. return { exitCode: 0, stdout: this.foreground, stderr: '' }
  161. }
  162. if (command.startsWith('set -o pipefail; ps -eo sid=')) {
  163. if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
  164. const groups = command.includes('stat=') && command.includes('$3 !~ /^[ZXx]/')
  165. ? this.groups
  166. : [...this.groups, ...this.zombieGroups]
  167. return { exitCode: 0, stdout: groups.map(group => `${group}\n`).join(''), stderr: '' }
  168. }
  169. if (command.startsWith('kill -TERM -- ')) {
  170. if (this.termFailure !== undefined) throw this.termFailure
  171. if (this.clearOnTerm) {
  172. this.groups = []
  173. this.handle.fail(143)
  174. }
  175. }
  176. if (command.startsWith('kill -INT -- ')) {
  177. await this.signalRequest?.(options?.signal)
  178. options?.signal?.throwIfAborted()
  179. }
  180. if (command.startsWith('kill -KILL -- ') && this.clearOnKill) this.groups = []
  181. return { exitCode: 0, stdout: '', stderr: '' }
  182. },
  183. },
  184. pty: {
  185. create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
  186. this.createOptions = options
  187. if (this.createError !== undefined) throw this.createError
  188. await this.createGate
  189. options.signal?.throwIfAborted()
  190. await options.onData(Buffer.from('buffered banner\n'))
  191. return this.handle.asHandle()
  192. },
  193. sendInput: async (pid: number, data: Uint8Array, options?: { signal?: AbortSignal }): Promise<void> => {
  194. options?.signal?.throwIfAborted()
  195. await this.sendInputRequest?.(options?.signal)
  196. options?.signal?.throwIfAborted()
  197. this.inputs.push({ pid, data: Buffer.from(data) })
  198. if (this.sendError !== undefined) throw this.sendError
  199. if (this.emitOutputMarker && Buffer.from(data).includes(Buffer.from('runner.bash'))) {
  200. const marker = [...this.writes].find(([path]) => path.endsWith('/output-marker'))?.[1]
  201. const onData = this.createOptions?.onData
  202. if (marker !== undefined && onData !== undefined) {
  203. await onData(Buffer.from(Buffer.from(data).toString().replace(/\r$/, '\r\n')))
  204. const split = Math.floor(marker.length / 2)
  205. await onData(Buffer.from(marker.slice(0, split)))
  206. await onData(Buffer.from(marker.slice(split)))
  207. await onData(Buffer.from(this.requestedOutput))
  208. }
  209. }
  210. },
  211. },
  212. } as unknown as Sandbox
  213. }
  214. function runtime(fake: FakeTerminalSandbox): E2BRuntime {
  215. return {
  216. cwd: '/workspace',
  217. runtimeRoot: '/workspace/.dsh-e2b',
  218. getSandbox: async () => fake.sandbox,
  219. } as unknown as E2BRuntime
  220. }
  221. function spec(overrides: Partial<SubprocessTerminalSpawnSpec> = {}): SubprocessTerminalSpawnSpec {
  222. return {
  223. argv: ['/bin/bash', '--noprofile', '--norc'],
  224. cwd: '/workspace',
  225. rows: 24,
  226. cols: 80,
  227. graceMs: 5,
  228. env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' },
  229. ...overrides,
  230. }
  231. }
  232. function holdRequestUntilAbort(started: PromiseWithResolvers<AbortSignal>) {
  233. return async (signal: AbortSignal | undefined): Promise<void> => {
  234. if (signal === undefined) throw new Error('expected an operation signal')
  235. signal.throwIfAborted()
  236. started.resolve(signal)
  237. await new Promise<void>((_resolve, reject) => {
  238. signal.addEventListener('abort', () => {
  239. reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
  240. }, { once: true })
  241. })
  242. }
  243. }
  244. /** Spawn the terminal under test with the config default the service would pass. */
  245. function testSpawn(
  246. runtime: Parameters<typeof spawnE2BTerminal>[0],
  247. spec: Parameters<typeof spawnE2BTerminal>[1],
  248. stateDir: string,
  249. pollMs = 20,
  250. ): ReturnType<typeof spawnE2BTerminal> {
  251. return spawnE2BTerminal(runtime, spec, stateDir, pollMs)
  252. }
  253. describe('E2B terminal allocation', () => {
  254. it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => {
  255. const fake = new FakeTerminalSandbox()
  256. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one')
  257. let output = ''
  258. terminal.output.on('data', (chunk) => { output += String(chunk) })
  259. await new Promise(resolve => setTimeout(resolve, 0))
  260. expect(output).toBe('requested-shell$ ')
  261. expect(output).not.toContain('buffered banner')
  262. expect(output).not.toContain('runner.bash')
  263. expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0 })
  264. const controlEnvs = fake.createOptions?.envs
  265. expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
  266. expect(controlEnvs).toEqual({
  267. TERM: 'dumb',
  268. NPM_TOKEN: '',
  269. DSH_STALE: '',
  270. HOME: controlEnvs?.HOME,
  271. })
  272. expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
  273. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
  274. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0')
  275. expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('TOKEN_EXPLICIT=kept\0')
  276. expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('secret')
  277. expect(fake.writes.get('/runtime/terminal-one/environment')).not.toContain('DSH_STALE')
  278. expect(fake.writes.get('/runtime/terminal-one/argv')).toBe('/bin/bash\0--noprofile\0--norc\0')
  279. const marker = fake.writes.get('/runtime/terminal-one/output-marker') ?? ''
  280. expect(marker).toMatch(/^dsh-e2b-bootstrap:/)
  281. expect(fake.inputs[0]?.data.toString()).not.toContain(marker)
  282. const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
  283. expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
  284. expect(runner).toContain('printf \'%s\' "$dsh_output_marker"')
  285. expect(runner).toContain('exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"')
  286. expect(runner).not.toContain('\u007f')
  287. terminal.output.destroy()
  288. await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
  289. expect(output).toBe('requested-shell$ ')
  290. await terminal.write('echo ok\r')
  291. expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r')
  292. await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false })
  293. await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456)
  294. expect(fake.commands).toContain('kill -INT -- -456')
  295. const terminated = terminal.terminate()
  296. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  297. await terminated
  298. expect(fake.handle.disconnects).toBe(1)
  299. expect(fake.removed).toContain('/runtime/terminal-one')
  300. })
  301. it('inherits only safe ambient values and limits the allocation signal to setup', async () => {
  302. const fake = new FakeTerminalSandbox()
  303. const controller = new AbortController()
  304. const terminal = await testSpawn(
  305. runtime(fake),
  306. spec({ env: undefined, signal: controller.signal }),
  307. '/runtime/abort-live',
  308. )
  309. const environment = fake.writes.get('/runtime/abort-live/environment') ?? ''
  310. expect(environment).toContain('KEEP=visible\0')
  311. expect(environment).not.toContain('secret')
  312. expect(environment).not.toContain('DSH_STALE')
  313. controller.abort(new Error('stop'))
  314. await terminal.write('still live\r')
  315. expect(fake.inputs.at(-1)?.data.toString()).toBe('still live\r')
  316. await terminal.terminate()
  317. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  318. })
  319. it('publishes the PTY handle before honoring allocation cancellation', async () => {
  320. const fake = new FakeTerminalSandbox()
  321. fake.deferCreate()
  322. const controller = new AbortController()
  323. const spawning = testSpawn(
  324. runtime(fake),
  325. spec({ signal: controller.signal }),
  326. '/runtime/allocation-cancel',
  327. )
  328. await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
  329. controller.abort(new Error('allocation cancelled'))
  330. fake.releaseCreate()
  331. await expect(spawning).rejects.toThrow('allocation cancelled')
  332. expect(fake.createOptions?.signal).toBeUndefined()
  333. expect(fake.groups).toEqual([])
  334. expect(fake.handle.disconnects).toBe(1)
  335. })
  336. it('rejects malformed environment and argv values before PTY allocation', async () => {
  337. const invalidName = new FakeTerminalSandbox()
  338. await expect(testSpawn(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
  339. .rejects.toThrow('environment entries')
  340. expect(invalidName.createOptions).toBeUndefined()
  341. const invalidValue = new FakeTerminalSandbox()
  342. await expect(testSpawn(runtime(invalidValue), spec({ env: { BAD: 'x\0y' } }), '/runtime/value'))
  343. .rejects.toThrow('environment entries')
  344. const invalidArg = new FakeTerminalSandbox()
  345. await expect(testSpawn(runtime(invalidArg), spec({ argv: ['/bin/bash', 'x\0y'] }), '/runtime/argv'))
  346. .rejects.toThrow('argv must not contain NUL')
  347. })
  348. it('cleans malformed handles, bootstrap failures, and readiness failures', async () => {
  349. const failedState = new FakeTerminalSandbox()
  350. failedState.writeError = new Error('state write failed')
  351. await expect(testSpawn(runtime(failedState), spec(), '/runtime/state-write'))
  352. .rejects.toThrow('state write failed')
  353. expect(failedState.writes.get('/runtime/state-write/environment')).toContain('KEEP=visible\0')
  354. expect(failedState.removed).toContain('/runtime/state-write')
  355. expect(failedState.createOptions).toBeUndefined()
  356. const stateAlreadyGone = new FakeTerminalSandbox()
  357. stateAlreadyGone.writeError = new Error('state write failed after external cleanup')
  358. stateAlreadyGone.removeError = new FileNotFoundError('state already gone')
  359. await expect(testSpawn(runtime(stateAlreadyGone), spec(), '/runtime/state-gone'))
  360. .rejects.toThrow('state write failed after external cleanup')
  361. const invalidPid = new FakeTerminalSandbox()
  362. invalidPid.handle.pid = 0
  363. await expect(testSpawn(runtime(invalidPid), spec(), '/runtime/invalid-pid'))
  364. .rejects.toThrow('invalid terminal pid 0')
  365. expect(invalidPid.handle.sdkKills).toBe(1)
  366. expect(invalidPid.removed).toContain('/runtime/invalid-pid')
  367. const failedInput = new FakeTerminalSandbox()
  368. failedInput.sendError = new Error('bootstrap failed')
  369. await expect(testSpawn(runtime(failedInput), spec(), '/runtime/input'))
  370. .rejects.toThrow('bootstrap failed')
  371. expect(failedInput.commands).toContain('kill -TERM -- -123')
  372. expect(failedInput.groups).toEqual([])
  373. const invalidSession = new FakeTerminalSandbox()
  374. invalidSession.sessionId = 'not-a-session\n'
  375. invalidSession.clearOnTerm = false
  376. await expect(testSpawn(runtime(invalidSession), spec(), '/runtime/session'))
  377. .rejects.toThrow('cannot resolve process session')
  378. expect(invalidSession.commands).toContain('kill -TERM -- -123')
  379. expect(invalidSession.commands).toContain('kill -KILL -- -123')
  380. expect(invalidSession.groups).toEqual([])
  381. expect(invalidSession.handle.sdkKills).toBe(1)
  382. const lateData = invalidSession.createOptions?.onData
  383. if (lateData === undefined) throw new Error('missing captured terminal callback')
  384. expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
  385. const termFailed = new FakeTerminalSandbox()
  386. termFailed.sendError = new Error('bootstrap failed')
  387. termFailed.termFailure = new Error('TERM transport failed')
  388. await expect(testSpawn(runtime(termFailed), spec(), '/runtime/term-failed'))
  389. .rejects.toThrow('bootstrap failed')
  390. expect(termFailed.commands).toContain('kill -KILL -- -123')
  391. expect(termFailed.handle.sdkKills).toBe(1)
  392. const uninspectable = new FakeTerminalSandbox()
  393. uninspectable.sendError = new Error('bootstrap failed')
  394. uninspectable.sessionGroupsFailure = 'session enumeration failed'
  395. uninspectable.handle.sdkKillError = new Error('PTY kill failed')
  396. let uninspectableFailure: unknown
  397. try {
  398. await testSpawn(runtime(uninspectable), spec(), '/runtime/uninspectable')
  399. } catch (error: unknown) {
  400. uninspectableFailure = error
  401. }
  402. expect(uninspectableFailure).toBeInstanceOf(AggregateError)
  403. expect(uninspectable.handle.sdkKills).toBe(1)
  404. const survivingGroups = new FakeTerminalSandbox()
  405. survivingGroups.sendError = new Error('bootstrap failed')
  406. survivingGroups.clearOnTerm = false
  407. survivingGroups.clearOnKill = false
  408. await expect(testSpawn(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups'))
  409. .rejects.toThrow('bootstrap failed')
  410. const survivingPid = new FakeTerminalSandbox()
  411. survivingPid.sendError = new Error('bootstrap failed')
  412. survivingPid.groups = []
  413. survivingPid.handle.settleOnSdkKill = false
  414. await expect(testSpawn(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
  415. .rejects.toThrow('bootstrap failed')
  416. const waitFailed = new FakeTerminalSandbox()
  417. waitFailed.handle.waitError = new Error('wait failed')
  418. waitFailed.handle.settleOnSdkKill = false
  419. waitFailed.handle.sdkKillError = new Error('kill failed')
  420. await expect(testSpawn(runtime(waitFailed), spec(), '/runtime/wait-failed'))
  421. .rejects.toThrow('wait failed')
  422. expect(waitFailed.handle.sdkKills).toBe(1)
  423. const cleanupFailed = new FakeTerminalSandbox()
  424. cleanupFailed.handle.pid = 0
  425. cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
  426. cleanupFailed.removeError = new Error('remove transport failed')
  427. await expect(testSpawn(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
  428. .rejects.toThrow('invalid terminal pid 0')
  429. const expiredDuringRollback = new FakeTerminalSandbox()
  430. expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
  431. expiredDuringRollback.groups = []
  432. expiredDuringRollback.handle.settleOnSdkKill = false
  433. expiredDuringRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  434. expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
  435. await expect(testSpawn(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
  436. .rejects.toThrow('bootstrap failed before timeout')
  437. expect(expiredDuringRollback.handle.sdkKills).toBe(1)
  438. const expiredBeforeSdkRollback = new FakeTerminalSandbox()
  439. expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
  440. expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  441. expiredBeforeSdkRollback.handle.settleOnSdkKill = false
  442. await expect(testSpawn(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
  443. .rejects.toThrow('wait failed after timeout')
  444. const missingDuringDisconnect = new FakeTerminalSandbox()
  445. missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
  446. missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
  447. await expect(testSpawn(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect'))
  448. .rejects.toThrow('bootstrap failed before disconnect')
  449. const failedDisconnect = new FakeTerminalSandbox()
  450. failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
  451. failedDisconnect.handle.disconnectError = new Error('disconnect transport failed')
  452. await expect(testSpawn(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect'))
  453. .rejects.toThrow('bootstrap failed with disconnect failure')
  454. })
  455. it('propagates setup cancellation and provider failures', async () => {
  456. const aborted = new FakeTerminalSandbox()
  457. await expect(testSpawn(runtime(aborted), spec({ signal: AbortSignal.abort(new Error('stop')) }), '/runtime/abort'))
  458. .rejects.toThrow('stop')
  459. const createFailed = new FakeTerminalSandbox()
  460. createFailed.createError = new Error('create failed')
  461. await expect(testSpawn(runtime(createFailed), spec(), '/runtime/create'))
  462. .rejects.toThrow('create failed')
  463. })
  464. it('bounds a missing bootstrap-output boundary by process exit or cancellation', async () => {
  465. const exited = new FakeTerminalSandbox()
  466. exited.emitOutputMarker = false
  467. const exiting = testSpawn(runtime(exited), spec(), '/runtime/missing-output-boundary')
  468. await vi.waitFor(() => { expect(exited.inputs).toHaveLength(1) })
  469. exited.handle.succeed(0)
  470. await expect(exiting).rejects.toThrow('terminal exited before publishing its output boundary')
  471. const cancelled = new FakeTerminalSandbox()
  472. cancelled.emitOutputMarker = false
  473. const controller = new AbortController()
  474. const cancelling = testSpawn(
  475. runtime(cancelled),
  476. spec({ signal: controller.signal }),
  477. '/runtime/cancel-output-boundary',
  478. )
  479. await vi.waitFor(() => { expect(cancelled.inputs).toHaveLength(1) })
  480. await new Promise(resolve => setTimeout(resolve, 0))
  481. controller.abort(new Error('cancel output boundary'))
  482. await expect(cancelling).rejects.toThrow('cancel output boundary')
  483. })
  484. })
  485. describe('E2B terminal lifecycle', () => {
  486. it('aborts and joins in-flight terminal operations before cleanup', async () => {
  487. const fake = new FakeTerminalSandbox()
  488. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/in-flight-operations')
  489. const writeStarted = Promise.withResolvers<AbortSignal>()
  490. const inspectStarted = Promise.withResolvers<AbortSignal>()
  491. const signalStarted = Promise.withResolvers<AbortSignal>()
  492. fake.sendInputRequest = holdRequestUntilAbort(writeStarted)
  493. let foregroundRequests = 0
  494. fake.foregroundRequest = async (signal) => {
  495. foregroundRequests += 1
  496. if (foregroundRequests === 1) await holdRequestUntilAbort(inspectStarted)(signal)
  497. }
  498. let signalCompleted = false
  499. fake.signalRequest = async (operationSignal) => {
  500. await holdRequestUntilAbort(signalStarted)(operationSignal)
  501. signalCompleted = true
  502. }
  503. const write = terminal.write('late input')
  504. const inspect = terminal.inspectForeground()
  505. await Promise.all([writeStarted.promise, inspectStarted.promise])
  506. const signal = terminal.signalForeground('SIGINT')
  507. await signalStarted.promise
  508. const terminating = terminal.terminate()
  509. await expect(write).rejects.toThrow('terminal is terminating')
  510. await expect(inspect).rejects.toThrow('terminal is terminating')
  511. await expect(signal).rejects.toThrow('terminal is terminating')
  512. await terminating
  513. expect(signalCompleted).toBe(false)
  514. expect(fake.inputs).toHaveLength(1)
  515. const commandCount = fake.commands.length
  516. await expect(terminal.write('after termination')).rejects.toThrow('terminal is terminating')
  517. await expect(terminal.inspectForeground()).rejects.toThrow('terminal is terminating')
  518. await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('terminal is terminating')
  519. expect(fake.commands).toHaveLength(commandCount)
  520. })
  521. it('maps ordinary exits, closes output, and reports an absent foreground after exit', async () => {
  522. const fake = new FakeTerminalSandbox()
  523. fake.groups = []
  524. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/natural')
  525. terminal.output.resume()
  526. const ended = once(terminal.output, 'end')
  527. fake.handle.succeed(7)
  528. await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null })
  529. await ended
  530. await expect(terminal.write('late')).rejects.toThrow('exited')
  531. fake.foregroundFailure = commandError(1)
  532. await expect(terminal.inspectForeground()).resolves.toBeUndefined()
  533. await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('cannot resolve foreground process group')
  534. await terminal.terminate()
  535. })
  536. it.each([
  537. [7, { exitCode: 7, signal: null }],
  538. [143, { exitCode: 143, signal: null }],
  539. [255, { exitCode: 255, signal: null }],
  540. ] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
  541. const fake = new FakeTerminalSandbox()
  542. fake.groups = []
  543. const terminal = await testSpawn(runtime(fake), spec(), `/runtime/exit-${exitCode}`)
  544. fake.handle.fail(exitCode)
  545. await expect(terminal.done).resolves.toEqual(expected)
  546. await terminal.terminate()
  547. })
  548. it('treats a terminal session containing only zombies as quiescent', async () => {
  549. const fake = new FakeTerminalSandbox()
  550. fake.groups = []
  551. fake.zombieGroups = [123]
  552. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/zombie-session')
  553. fake.handle.succeed(0)
  554. await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
  555. await terminal.terminate()
  556. expect(fake.commands).toContain(
  557. "set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'",
  558. )
  559. })
  560. it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
  561. const fake = new FakeTerminalSandbox()
  562. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/expired-sandbox')
  563. fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
  564. fake.handle.succeed(0)
  565. await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
  566. await terminal.terminate()
  567. })
  568. it('treats sandbox disappearance during PTY kill as quiescent', async () => {
  569. const fake = new FakeTerminalSandbox()
  570. fake.groups = []
  571. fake.handle.settleOnSdkKill = false
  572. fake.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
  573. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
  574. await terminal.terminate()
  575. expect(fake.handle.sdkKills).toBe(1)
  576. })
  577. it('propagates a non-missing PTY kill failure', async () => {
  578. const fake = new FakeTerminalSandbox()
  579. fake.groups = []
  580. fake.handle.settleOnSdkKill = false
  581. fake.handle.sdkKillError = new Error('PTY kill transport failed')
  582. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
  583. await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
  584. fake.handle.sdkKillError = undefined
  585. fake.handle.succeed(0)
  586. await terminal.done
  587. await terminal.terminate()
  588. })
  589. it.each([
  590. ['accepts sandbox loss', new SandboxNotFoundError('sandbox expired'), true],
  591. ['propagates another failure', new Error('disconnect failed'), false],
  592. ] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
  593. const fake = new FakeTerminalSandbox()
  594. const terminal = await testSpawn(runtime(fake), spec(), `/runtime/disconnect-${accepted}`)
  595. fake.handle.disconnectError = failure
  596. fake.groups = []
  597. fake.handle.succeed(0)
  598. if (accepted) await expect(terminal.terminate()).resolves.toBeUndefined()
  599. else await expect(terminal.terminate()).rejects.toThrow('disconnect failed')
  600. })
  601. it('rejects killing the terminal shell and propagates live foreground failures', async () => {
  602. const fake = new FakeTerminalSandbox()
  603. fake.foreground = '123\n'
  604. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/signal')
  605. await expect(terminal.signalForeground('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
  606. fake.foreground = 'invalid\n'
  607. await expect(terminal.inspectForeground()).rejects.toThrow('cannot resolve foreground')
  608. fake.foregroundFailure = commandError(1)
  609. await expect(terminal.inspectForeground()).resolves.toBeUndefined()
  610. fake.foregroundFailure = commandError(2)
  611. await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
  612. fake.clearOnTerm = true
  613. await terminal.terminate()
  614. })
  615. it('sends KILL before checking an expired force-cleanup deadline', async () => {
  616. const fake = new FakeTerminalSandbox()
  617. fake.groups = [123, 456]
  618. fake.clearOnTerm = false
  619. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 0 }), '/runtime/escalate')
  620. const terminating = terminal.terminate()
  621. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  622. await terminating
  623. expect(fake.commands).toContain('kill -TERM -- -123 -456')
  624. expect(fake.commands).toContain('kill -KILL -- -123 -456')
  625. })
  626. it('surfaces cleanup failures and allows a later retry', async () => {
  627. const fake = new FakeTerminalSandbox()
  628. fake.groups = [1]
  629. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry')
  630. await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1')
  631. fake.groups = []
  632. fake.handle.succeed(0)
  633. await terminal.done
  634. await terminal.terminate()
  635. })
  636. it('propagates a process-group signalling transport failure before retry', async () => {
  637. const fake = new FakeTerminalSandbox()
  638. fake.termFailure = new Error('signal transport failed')
  639. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure')
  640. await expect(terminal.terminate()).rejects.toThrow('signal transport failed')
  641. fake.groups = []
  642. fake.handle.succeed(0)
  643. await terminal.done
  644. await terminal.terminate()
  645. const alreadyExited = new FakeTerminalSandbox()
  646. alreadyExited.termFailure = commandError(1)
  647. const tolerant = await testSpawn(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited')
  648. const tolerantTermination = tolerant.terminate()
  649. await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  650. await tolerantTermination
  651. })
  652. it('keeps command rejection authoritative while cleanup is already waiting', async () => {
  653. const fake = new FakeTerminalSandbox()
  654. fake.groups = []
  655. fake.removeError = new Error('private state already gone')
  656. const terminal = await testSpawn(runtime(fake), spec(), '/runtime/reject-during-cleanup')
  657. terminal.output.on('error', () => {})
  658. const cleanup = terminal.terminate()
  659. await Promise.resolve()
  660. fake.handle.crash(new Error('command transport failed'))
  661. await expect(terminal.done).rejects.toThrow('command transport failed')
  662. await cleanup
  663. })
  664. it('keeps a late command rejection authoritative after PTY kill', async () => {
  665. const fake = new FakeTerminalSandbox()
  666. fake.groups = []
  667. fake.handle.settleOnSdkKill = false
  668. const terminal = await testSpawn(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
  669. terminal.output.on('error', () => {})
  670. const cleanup = terminal.terminate()
  671. while (fake.handle.sdkKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
  672. await Promise.resolve()
  673. fake.handle.crash(new Error('late command transport failed'))
  674. await expect(terminal.done).rejects.toThrow('late command transport failed')
  675. await cleanup
  676. })
  677. it('reports surviving groups, a surviving top-level pid, and transport failure', async () => {
  678. const survivor = new FakeTerminalSandbox()
  679. survivor.clearOnTerm = false
  680. survivor.clearOnKill = false
  681. const terminal = await testSpawn(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor')
  682. await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123')
  683. const livePid = new FakeTerminalSandbox()
  684. livePid.groups = []
  685. livePid.handle.settleOnSdkKill = false
  686. const live = await testSpawn(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
  687. await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
  688. livePid.handle.succeed(0)
  689. await live.done
  690. const crashed = new FakeTerminalSandbox()
  691. crashed.groups = []
  692. const failed = await testSpawn(runtime(crashed), spec(), '/runtime/crashed')
  693. const outputError = once(failed.output, 'error')
  694. crashed.handle.crash('transport gone')
  695. await expect(failed.done).rejects.toEqual('transport gone')
  696. await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }])
  697. await failed.terminate()
  698. })
  699. })
  700. describe('E2B subprocess terminal service', () => {
  701. async function service(fake = new FakeTerminalSandbox()): Promise<{
  702. ctx: Context
  703. fiber: Awaited<ReturnType<Context['plugin']>>
  704. fake: FakeTerminalSandbox
  705. }> {
  706. const ctx = new Context()
  707. ctx.provide('e2b', runtime(fake))
  708. const fiber = await ctx.plugin(E2BSubprocessRuntime)
  709. return { ctx, fiber, fake }
  710. }
  711. it('resolves remote executables', async () => {
  712. const { ctx, fake } = await service()
  713. await expect(ctx.subprocess.resolveExecutable('/bin/bash')).resolves.toBe('/bin/bash')
  714. await expect(ctx.subprocess.resolveExecutable('node', { PATH: '/custom/bin' }, new AbortController().signal))
  715. .resolves.toBe('/usr/bin/node')
  716. fake.resolvedExecutable = 'tools/bin/node\n'
  717. await expect(ctx.subprocess.resolveExecutable('node', { PATH: 'tools/bin' }))
  718. .resolves.toBe('/workspace/tools/bin/node')
  719. const commandOptions = fake.commandOptions.at(-1)
  720. expect(commandOptions).toMatchObject({ cwd: '/workspace' })
  721. expect(commandOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
  722. expect(commandOptions?.envs).toEqual({ HOME: commandOptions?.envs?.HOME })
  723. expect((ctx.e2b)).toBeDefined()
  724. })
  725. it('rejects invalid executable lookup inputs and results', async () => {
  726. const { ctx, fake } = await service()
  727. await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('non-empty')
  728. await expect(ctx.subprocess.resolveExecutable('./bin/server')).rejects.toThrow('is a relative path')
  729. await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server')).rejects.toThrow('is a relative path')
  730. await expect(ctx.subprocess.resolveExecutable('node', undefined, AbortSignal.abort(new Error('stop'))))
  731. .rejects.toThrow('stop')
  732. fake.resolvedExecutable = 'node\n'
  733. await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
  734. fake.resolvedExecutable = '/one\n/two\n'
  735. await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
  736. })
  737. it('rejects a non-positive poll cadence at load', async () => {
  738. const ctx = new Context()
  739. ctx.provide('e2b', runtime(new FakeTerminalSandbox()))
  740. await expect(ctx.plugin(E2BSubprocessRuntime, { pollMs: 0 }))
  741. .rejects.toThrow('pollMs must be a positive safe integer')
  742. const explicit = await ctx.plugin(E2BSubprocessRuntime, { pollMs: 5 })
  743. await explicit.dispose()
  744. })
  745. it('owns live terminals through service disposal', async () => {
  746. const { ctx, fiber, fake } = await service()
  747. const terminal = await ctx.subprocess.spawnTerminal(spec({ signal: new AbortController().signal }))
  748. await fiber.dispose()
  749. await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
  750. expect(fake.handle.disconnects).toBe(1)
  751. })
  752. it('joins and rejects terminal setup that completes during service disposal', async () => {
  753. const fake = new FakeTerminalSandbox()
  754. const { ctx, fiber } = await service(fake)
  755. let disposing: Promise<void> | undefined
  756. fake.afterSessionLookup = () => {
  757. fake.afterSessionLookup = undefined
  758. queueMicrotask(() => {
  759. queueMicrotask(() => { disposing = fiber.dispose() })
  760. })
  761. }
  762. const subprocess = ctx.subprocess
  763. const spawning = ctx.subprocess.spawnTerminal(spec())
  764. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  765. await vi.waitFor(() => { expect(disposing).toBeDefined() })
  766. await expect(subprocess.spawnTerminal(spec())).rejects.toThrow('service is disposing')
  767. await rejected
  768. await disposing
  769. expect(fake.groups).toEqual([])
  770. expect(fake.handle.disconnects).toBe(1)
  771. expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
  772. })
  773. it('aborts and rolls back terminal setup that cannot publish its output boundary during disposal', async () => {
  774. const fake = new FakeTerminalSandbox()
  775. fake.emitOutputMarker = false
  776. const { ctx, fiber } = await service(fake)
  777. const spawning = ctx.subprocess.spawnTerminal(spec())
  778. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  779. await vi.waitFor(() => { expect(fake.inputs).toHaveLength(1) })
  780. await fiber.dispose()
  781. await rejected
  782. expect(fake.groups).toEqual([])
  783. expect(fake.handle.disconnects).toBe(1)
  784. })
  785. it('owns and cancels terminal state-directory creation during disposal', async () => {
  786. const fake = new FakeTerminalSandbox()
  787. fake.makeDirRequest = async (signal) => {
  788. await new Promise<never>((_resolve, reject) => {
  789. const onAbort = (): void => {
  790. const reason: unknown = signal?.reason
  791. reject(reason instanceof Error ? reason : new Error(String(reason)))
  792. }
  793. signal?.addEventListener('abort', onAbort, { once: true })
  794. if (signal?.aborted === true) onAbort()
  795. })
  796. }
  797. const { ctx, fiber } = await service(fake)
  798. const spawning = ctx.subprocess.spawnTerminal(spec())
  799. const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
  800. await vi.waitFor(() => { expect(fake.directories.some(path => path.includes('/terminals/'))).toBe(true) })
  801. await fiber.dispose()
  802. await rejected
  803. expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
  804. expect(fake.createOptions).toBeUndefined()
  805. })
  806. it('releases naturally settled terminals and validates terminal requests', async () => {
  807. const { ctx, fiber, fake } = await service()
  808. for (const request of [
  809. spec({ argv: [] }),
  810. spec({ signal: AbortSignal.abort(new Error('cancelled')) }),
  811. ]) {
  812. await expect(ctx.subprocess.spawnTerminal(request)).rejects.toThrow()
  813. }
  814. fake.groups = []
  815. const terminal = await ctx.subprocess.spawnTerminal(spec())
  816. fake.handle.succeed(0)
  817. await terminal.done
  818. await terminal.terminate()
  819. const signals = fake.commands.filter(command => command.startsWith('kill -')).length
  820. await fiber.dispose()
  821. expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
  822. })
  823. it('contains a failed automatic terminal release until service disposal retries it', async () => {
  824. const { fiber, fake } = await service()
  825. fake.clearOnTerm = false
  826. fake.clearOnKill = false
  827. const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec({ graceMs: 1 }))
  828. fake.handle.succeed(0)
  829. await terminal.done
  830. await new Promise(resolve => setTimeout(resolve, 10))
  831. expect(fake.commands).toContain('kill -KILL -- -123')
  832. fake.groups = []
  833. await fiber.dispose()
  834. await expect(terminal.terminate()).resolves.toBeUndefined()
  835. })
  836. it('contains an immediate automatic terminal release rejection before disposal retries it', async () => {
  837. const { fiber, fake } = await service()
  838. fake.groups = []
  839. const terminal = await (fiber.ctx).subprocess.spawnTerminal(spec())
  840. const terminate = vi.spyOn(terminal, 'terminate')
  841. .mockRejectedValueOnce(new Error('automatic release failed'))
  842. fake.handle.succeed(0)
  843. await terminal.done
  844. await vi.waitFor(() => { expect(terminate).toHaveBeenCalledTimes(1) })
  845. await new Promise(resolve => setTimeout(resolve, 0))
  846. await fiber.dispose()
  847. expect(terminate).toHaveBeenCalledTimes(2)
  848. expect(fake.handle.disconnects).toBe(1)
  849. })
  850. })