index.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. /**
  2. * E2B Service Provider for the subprocess capability seam. Each handle starts through the
  3. * shared sandbox and retains command output/status paths in that remote world.
  4. * @module @deepseek-ai/dsh-subprocess-e2b
  5. */
  6. import { randomUUID } from 'node:crypto'
  7. import { posix } from 'node:path'
  8. import { inspect } from 'node:util'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import z from '@deepseek-ai/schemastery'
  11. import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
  12. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  13. import type {
  14. SubprocessHandle,
  15. SubprocessSpawnSpec,
  16. SubprocessTerminalHandle,
  17. SubprocessTerminalSpawnSpec,
  18. } from '@deepseek-ai/dsh-subprocess'
  19. import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
  20. import { E2BSubprocessHandle } from './process.ts'
  21. import { asError, signalOpts } from './remote.ts'
  22. import { spawnE2BTerminal } from './terminal.ts'
  23. /** Configuration for the E2B subprocess adapter. */
  24. export interface Config {
  25. /** Remote status/liveness poll cadence in milliseconds; each tick is one control-plane request. */
  26. pollMs?: number
  27. }
  28. interface SchemaResolvedConfig extends Config {
  29. pollMs: number
  30. }
  31. interface TerminalSetup {
  32. done: Promise<void>
  33. controller: AbortController
  34. }
  35. /**
  36. * Enforce the seam's documented grace bound (positive, finite, one Node timer),
  37. * matching subprocess-local's spawn-time check; an unbounded grace would make
  38. * the remote force-escalation deadline unreachable.
  39. * @param graceMs - The spec's cleanup grace in milliseconds.
  40. */
  41. function requireRepresentableGrace(graceMs: number): void {
  42. if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > MAX_TIMER_DELAY_MS) {
  43. throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
  44. }
  45. }
  46. function validateNoNullByte(subject: string, value: string): void {
  47. if (!value.includes('\0')) return
  48. const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`)
  49. Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' })
  50. throw error
  51. }
  52. /** E2B command manager registered as `ctx.subprocess`. */
  53. export class E2BSubprocessRuntime extends SubprocessRuntime {
  54. static inject = ['e2b']
  55. static Config: z<Config> = z.object({
  56. pollMs: z.number().default(20),
  57. })
  58. private readonly live = new Set<E2BSubprocessHandle>()
  59. private readonly terminals = new Set<SubprocessTerminalHandle>()
  60. private readonly terminalSetups = new Set<TerminalSetup>()
  61. private readonly pollMs: number
  62. private disposing = false
  63. /** Create the E2B subprocess service and bind its disposal policy. */
  64. constructor(ctx: Context, config: Config) {
  65. super(ctx)
  66. // Schemastery fills pollMs before construction; the type does not encode that step.
  67. const { pollMs } = config as SchemaResolvedConfig
  68. if (!Number.isSafeInteger(pollMs) || pollMs <= 0) {
  69. throw new Error('subprocess-e2b: pollMs must be a positive safe integer')
  70. }
  71. this.pollMs = pollMs
  72. ctx.effect(() => async () => {
  73. this.disposing = true
  74. for (const setup of this.terminalSetups) {
  75. setup.controller.abort(new Error('subprocess-e2b: service disposed during terminal setup'))
  76. }
  77. await Promise.all([...this.terminalSetups].map(setup => setup.done))
  78. const handles = [...this.live]
  79. const terminals = [...this.terminals]
  80. const pending: Promise<unknown>[] = []
  81. for (const handle of handles) {
  82. handle.terminate()
  83. pending.push(handle.waitForExit().then(async () => {
  84. await handle.done.catch(() => undefined)
  85. this.live.delete(handle)
  86. }))
  87. }
  88. for (const terminal of terminals) {
  89. pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
  90. }
  91. const outcomes = await Promise.allSettled(pending)
  92. const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
  93. ? [outcome.reason as unknown]
  94. : [])
  95. if (failures.length === 1) throw asError(failures[0])
  96. if (failures.length > 1) throw new AggregateError(failures, 'subprocess-e2b: teardown failed')
  97. }, 'e2b subprocess teardown')
  98. }
  99. /** @inheritdoc */
  100. async resolveExecutable(
  101. command: string,
  102. env?: Readonly<Record<string, string>>,
  103. signal?: AbortSignal,
  104. ): Promise<string> {
  105. if (command.length === 0) throw new Error('subprocess-e2b: executable name must be non-empty')
  106. signal?.throwIfAborted()
  107. const sandbox = await this.ctx.e2b.getSandbox()
  108. if (posix.isAbsolute(command)) {
  109. await sandbox.commands.run(
  110. `test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`,
  111. { envs: e2bControlEnvs(), ...signalOpts(signal) },
  112. )
  113. signal?.throwIfAborted()
  114. return command
  115. }
  116. if (command.includes('/')) {
  117. throw new Error(
  118. `subprocess-e2b: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
  119. )
  120. }
  121. const path = env?.PATH
  122. const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} `
  123. const result = await sandbox.commands.run(
  124. `${prefix}command -v -- ${quoteE2BShellArg(command)}`,
  125. { cwd: this.ctx.e2b.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) },
  126. )
  127. signal?.throwIfAborted()
  128. const executable = result.stdout.trim()
  129. if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) {
  130. throw new Error(`subprocess-e2b: executable ${JSON.stringify(command)} did not resolve to one absolute path`)
  131. }
  132. // A relative result comes from a relative PATH entry; the lookup ran with the shared cwd.
  133. return posix.resolve(this.ctx.e2b.cwd, executable)
  134. }
  135. /** @inheritdoc */
  136. spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
  137. if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
  138. const program = spec.argv[0]
  139. if (program === undefined || program.length === 0) {
  140. throw new Error('invalid argv: expected a non-empty program name at argv[0]')
  141. }
  142. requireRepresentableGrace(spec.graceMs)
  143. if (spec.signal?.aborted === true) {
  144. let reason = 'aborted'
  145. try {
  146. reason = String(spec.signal.reason ?? reason)
  147. } catch {
  148. // Arbitrary caller-owned reasons cannot escape the stable Error boundary.
  149. }
  150. throw new Error(`aborted before spawn: ${reason}`)
  151. }
  152. spec.argv.forEach((value, index) => {
  153. validateNoNullByte(index === 0 ? "The argument 'file'" : `The argument 'args[${String(index - 1)}]'`, value)
  154. })
  155. validateNoNullByte("The property 'options.cwd'", spec.cwd)
  156. for (const [key, value] of Object.entries(spec.env ?? {})) {
  157. if (value === undefined) continue
  158. validateNoNullByte(`The property 'options.env['${key}']'`, key)
  159. validateNoNullByte(`The property 'options.env['${key}']'`, value)
  160. }
  161. const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID())
  162. const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir, this.pollMs)
  163. this.live.add(handle)
  164. const release = async (): Promise<void> => {
  165. await handle.waitForExit()
  166. this.live.delete(handle)
  167. }
  168. void handle.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
  169. // Retain the handle so service disposal can retry its cleanup transaction.
  170. })
  171. return handle
  172. }
  173. /** @inheritdoc */
  174. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
  175. if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
  176. const program = spec.argv[0]
  177. if (program === undefined || program.length === 0) {
  178. throw new Error('subprocess-e2b: terminal argv must contain a program')
  179. }
  180. requireRepresentableGrace(spec.graceMs)
  181. spec.signal?.throwIfAborted()
  182. const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'terminals', randomUUID())
  183. const done = Promise.withResolvers<void>()
  184. const setup: TerminalSetup = { done: done.promise, controller: new AbortController() }
  185. const setupSignal = spec.signal === undefined
  186. ? setup.controller.signal
  187. : AbortSignal.any([spec.signal, setup.controller.signal])
  188. this.terminalSetups.add(setup)
  189. try {
  190. const terminal = await spawnE2BTerminal(
  191. this.ctx.e2b,
  192. { ...spec, signal: setupSignal },
  193. stateDir,
  194. this.pollMs,
  195. )
  196. this.terminals.add(terminal)
  197. // oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote allocation yields to disposal.
  198. if (this.disposing) {
  199. await terminal.terminate()
  200. this.terminals.delete(terminal)
  201. throw new Error('subprocess-e2b: service disposed during terminal setup')
  202. }
  203. const release = async (): Promise<void> => {
  204. await terminal.terminate()
  205. this.terminals.delete(terminal)
  206. }
  207. void terminal.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
  208. // Retain the terminal so service disposal can retry its cleanup transaction.
  209. })
  210. return terminal
  211. } finally {
  212. this.terminalSetups.delete(setup)
  213. done.resolve()
  214. }
  215. }
  216. }
  217. export default E2BSubprocessRuntime