index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. /** Confined Node programs with host-owned bindings, output limits, and managed process cleanup. */
  2. import { stripTypeScriptTypes } from 'node:module'
  3. import { isAbsolute } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import z from '@deepseek-ai/schemastery'
  6. import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
  7. import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
  8. import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
  9. import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
  10. import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  11. import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
  12. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  13. import type {} from '@deepseek-ai/dsh-fs'
  14. import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
  15. import { validateBindings } from './bindings.ts'
  16. import { JsonChannel } from './channel.ts'
  17. import { bootstrapArgs } from './launch.ts'
  18. import type { LaunchConfig } from './launch.ts'
  19. import { OutputLedger } from './output-ledger.ts'
  20. import { drainOutput } from './output-stream.ts'
  21. import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
  22. import { decodePtcJsonWire, encodePtcJsonWire } from './json-wire.ts'
  23. import type { ProgramBootData } from './protocol.ts'
  24. /** Deployment-varying runtime bounds and launch choices. */
  25. export interface Config extends LaunchConfig {
  26. /** Default elapsed deadline, including nested tool and approval waits. */
  27. timeoutMs?: number
  28. /** Maximum elapsed deadline accepted by resolve. */
  29. maxTimeoutMs?: number
  30. /** Combined serialized logs, completion and diagnostic byte cap. */
  31. maxOutputBytes?: number
  32. /** V8 old-generation heap limit in MiB; native allocations are excluded. */
  33. maxOldGenerationSizeMb?: number
  34. /** Maximum control frame, outstanding argument and queued control-output bytes. */
  35. maxMessageBytes?: number
  36. /** Maximum simultaneous host binding calls accepted from a program. */
  37. maxPendingCalls?: number
  38. /** Managed process termination and output-drain grace in milliseconds. */
  39. graceMs?: number
  40. }
  41. type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
  42. interface LiveRun { controller: AbortController; finished: Promise<void> }
  43. const STRIP_PREFIX = 'async function __dsh_program__() {\n'
  44. const STRIP_SUFFIX = '\n}'
  45. function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
  46. function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
  47. /** Node provider; direct file effects use the same sandbox service as Bash. */
  48. export class NodePtcRuntime extends PtcRuntime {
  49. static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
  50. static Config: z<Config> = z.object({
  51. timeoutMs: z.number().default(120_000),
  52. maxTimeoutMs: z.number().default(600_000),
  53. maxOutputBytes: z.number().default(67_108_864),
  54. maxOldGenerationSizeMb: z.number().default(512),
  55. maxMessageBytes: z.number().default(134_217_728),
  56. maxPendingCalls: z.number().default(128),
  57. graceMs: z.number().default(3_000),
  58. nodeExecutable: z.string(),
  59. bootstrapPath: z.string(),
  60. })
  61. readonly language = 'typescript'
  62. readonly isolation = 'process'
  63. override get executionInstructions(): string {
  64. return 'Each call runs in a fresh Node process. Node APIs are available through await import(...). Relative paths use the supplied working directory; process.env starts empty. Direct filesystem access follows this execution\'s sandbox policy.'
  65. }
  66. private readonly config: ResolvedConfig
  67. private readonly live = new Set<LiveRun>()
  68. private disposed = false
  69. constructor(ctx: Context, config: Config) {
  70. super(ctx)
  71. this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
  72. for (const [key, value] of Object.entries(this.config)) {
  73. if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`ptc-runtime-node: ${key} must be positive and finite`)
  74. }
  75. for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
  76. if (this.config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`)
  77. }
  78. if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4')
  79. if (!Number.isSafeInteger(this.config.maxMessageBytes) || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
  80. if (!Number.isSafeInteger(this.config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
  81. if (!Number.isSafeInteger(this.config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
  82. if (this.config.nodeExecutable.length === 0) throw new Error('ptc-runtime-node: nodeExecutable must be non-empty')
  83. if (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath)) throw new Error('ptc-runtime-node: bootstrapPath must be absolute')
  84. ctx.effect(() => async () => {
  85. this.disposed = true
  86. const active = [...this.live]
  87. for (const run of active) run.controller.abort('runtime disposed')
  88. await Promise.all(active.map(run => run.finished))
  89. }, 'Node ptc-runtime cleanup')
  90. }
  91. override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode }
  92. override get timeout(): { defaultMs: number; maxMs: number } {
  93. return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs }
  94. }
  95. /**
  96. * Resolve an execution under explicit or deployment policy.
  97. * @param request - Program, bindings, optional cwd/deadline, and resolved authority.
  98. * @returns Complete execution inputs with a capped deadline.
  99. */
  100. resolve(request: PtcRunRequest): PtcRunSpec {
  101. if (this.disposed) throw new Error('ptc-runtime-node: resolve after disposal')
  102. const sandboxPolicy = request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
  103. const cwd = request.cwd ?? sandboxPolicy.workspaceRoot
  104. if (!isAbsolute(cwd)) throw new Error('ptc-runtime-node: cwd must be absolute')
  105. return {
  106. ...request,
  107. cwd,
  108. timeoutMs: clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'ptc-runtime-node: timeoutMs'),
  109. sandboxPolicy,
  110. }
  111. }
  112. /**
  113. * Run a resolved program in a fresh managed and confined Node process.
  114. * @param spec - Inputs returned by resolve; missing authority is caller misuse.
  115. * @returns Output and file-confinement facts after managed cleanup.
  116. */
  117. async run(spec: PtcRunSpec): Promise<PtcRunResult> {
  118. if (this.disposed) throw new Error('ptc-runtime-node: run after disposal')
  119. if (spec.sandboxPolicy === undefined) throw new Error('ptc-runtime-node: run requires a resolved sandbox policy')
  120. if (!isAbsolute(spec.cwd) || !Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs) throw new Error('ptc-runtime-node: run requires resolved cwd and timeout')
  121. const bindings = validateBindings(spec)
  122. const controller = new AbortController()
  123. const completion = Promise.withResolvers<void>()
  124. const live = { controller, finished: completion.promise }
  125. this.live.add(live)
  126. try {
  127. return await this.execute(spec, spec.sandboxPolicy, bindings, controller)
  128. } finally {
  129. this.live.delete(live)
  130. completion.resolve()
  131. }
  132. }
  133. private async execute(
  134. spec: PtcRunSpec,
  135. policy: SandboxExecutionPolicy,
  136. bindings: Map<string, PtcBindingNamespace>,
  137. controller: AbortController,
  138. ): Promise<PtcRunResult> {
  139. const output = new OutputLedger(this.config.maxOutputBytes)
  140. const logs: string[] = []
  141. const sandbox: PtcRunSandbox = { mode: policy.mode, denied: false }
  142. const result = Promise.withResolvers<PtcRunResult>()
  143. const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
  144. let handle: SubprocessHandle | undefined
  145. let channel: JsonChannel | undefined
  146. let confined: ConfinedArgv | undefined
  147. let settled = false
  148. let timedOut = false
  149. let outputOverflow = false
  150. let overflowResult: PtcRunResult | undefined
  151. let stderr = ''
  152. let parsing = true
  153. const wallTimer = setTimeout(() => { timedOut = true; controller.abort('execution deadline reached') }, spec.timeoutMs)
  154. const finish = (failure?: PtcRunFailure, value?: PtcJsonValue): void => {
  155. if (settled) return
  156. settled = true
  157. clearTimeout(wallTimer)
  158. signal.removeEventListener('abort', onAbort)
  159. channel?.close()
  160. void (async () => {
  161. if (handle !== undefined) {
  162. try {
  163. handle.terminate()
  164. await Promise.all([handle.done.catch(() => {}), handle.waitForExit()])
  165. const drained = await Promise.all([
  166. drainOutput(handle.stdout, this.config.graceMs),
  167. drainOutput(handle.stderr, this.config.graceMs),
  168. ])
  169. if (drained.includes(false) && failure === undefined) {
  170. failure = { kind: 'worker-exit', message: 'Node process output did not close cleanly' }
  171. }
  172. } catch (error: unknown) {
  173. failure = { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(error)}` }
  174. } finally {
  175. handle.stdout?.destroy()
  176. handle.stderr?.destroy()
  177. }
  178. }
  179. const outcome = outputOverflow ? overflowResult ?? output.limit(logs)
  180. : failure === undefined ? output.success(logs, value) : output.failure(logs, failure)
  181. result.resolve({ ...outcome, sandbox: { ...sandbox } })
  182. })()
  183. }
  184. const onAbort = (): void => {
  185. finish(timedOut
  186. ? { kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms)` }
  187. : { kind: 'abort', message: messageOf(signal.reason) })
  188. }
  189. signal.addEventListener('abort', onAbort, { once: true })
  190. if (signal.aborted) onAbort()
  191. try {
  192. // Abort callbacks can settle execution before or during an awaited operation.
  193. // oxlint-disable-next-line typescript/no-unnecessary-condition
  194. if (settled) return await result.promise
  195. const stripped = stripTypeScriptTypes(STRIP_PREFIX + spec.program + STRIP_SUFFIX)
  196. parsing = false
  197. const data: ProgramBootData = {
  198. code: stripped.slice(STRIP_PREFIX.length, stripped.length - STRIP_SUFFIX.length),
  199. namespaces: [...bindings.values()].map(binding => ({
  200. global: binding.global,
  201. names: Object.keys(binding.functions),
  202. ...binding.errorClass ? { errorClass: binding.errorClass } : {},
  203. })),
  204. maxOutputBytes: this.config.maxOutputBytes,
  205. }
  206. const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, signal)
  207. // Abort callbacks can settle execution before or during an awaited operation.
  208. // oxlint-disable-next-line typescript/no-unnecessary-condition
  209. if (settled) return await result.promise
  210. const packaged = 'pkg' in process && this.config.bootstrapPath === undefined
  211. const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
  212. const argv = [executable, ...packaged ? [] : [heapFlag], ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
  213. confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)
  214. // oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
  215. if (settled) return await result.promise
  216. if (confined !== undefined) sandbox.enforcement = confined.enforcement
  217. // Native launchers need executable search and Windows system paths before the child installs its model environment.
  218. const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
  219. .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()))
  220. .map(key => [key, undefined]))
  221. if (packaged) {
  222. env.DSH_PTC_RUNTIME_NODE = '1'
  223. env.NODE_OPTIONS = heapFlag
  224. }
  225. handle = this.ctx.subprocess.spawn({ argv: confined?.argv ?? argv, cwd: spec.cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs, signal })
  226. const launched = handle
  227. if (launched.control === undefined || launched.stdout === undefined || launched.stderr === undefined) {
  228. throw new Error('subprocess provider did not supply the requested control and output pipes')
  229. }
  230. const admit = (text: string): void => {
  231. if (outputOverflow) return
  232. if (!output.admit(text, logs)) {
  233. outputOverflow = true
  234. overflowResult = output.limit([...logs, text])
  235. finish({ kind: 'output-limit', message: `outer output exceeded ${this.config.maxOutputBytes} bytes` })
  236. }
  237. }
  238. const stdoutDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
  239. const stderrDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
  240. handle.stdout?.on('data', (chunk: Buffer) => {
  241. const text = stdoutDecoder.decode(chunk, { stream: true })
  242. if (text.length > 0) admit(text)
  243. })
  244. handle.stdout?.on('end', () => {
  245. const text = stdoutDecoder.decode()
  246. if (text.length > 0) admit(text)
  247. })
  248. handle.stdout?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
  249. handle.stderr?.on('data', (chunk: Buffer) => {
  250. const text = stderrDecoder.decode(chunk, { stream: true })
  251. stderr = (stderr + text).slice(-this.config.maxOutputBytes)
  252. if (text.length > 0) admit(text)
  253. })
  254. handle.stderr?.on('end', () => {
  255. const text = stderrDecoder.decode()
  256. if (text.length > 0) admit(text)
  257. })
  258. handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
  259. let ready = false
  260. let nextId = 1
  261. let pending = 0
  262. let pendingBytes = 0
  263. const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) }
  264. const processFinished = (outcome: SubprocessOutcome): void => {
  265. if (settled) return
  266. finish((confined !== undefined && classifyRunnerFailure(outcome.exitCode, stderr, confined.runnerFailureRules) !== undefined)
  267. ? { kind: 'sandbox-unavailable', message: stderr }
  268. : { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` })
  269. }
  270. const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => {
  271. if (!record(raw)) { protocolFailure('invalid control frame'); return }
  272. if (!ready) {
  273. if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return }
  274. ready = true
  275. void transport.send({ type: 'boot', data }).catch((error: unknown) => { protocolFailure(messageOf(error)) })
  276. return
  277. }
  278. switch (raw.type) {
  279. case 'log':
  280. if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return }
  281. admit(raw.text)
  282. return
  283. case 'output-limit': outputOverflow = true; finish(); return
  284. case 'done': {
  285. if (raw.error !== undefined) {
  286. if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return }
  287. const failure = { kind: raw.error.kind, message: raw.error.message } as PtcRunFailure
  288. if (confined !== undefined) {
  289. sandbox.denied = confined.denialSignatures.some(signature =>
  290. failure.message.toLowerCase().includes(signature.toLowerCase()))
  291. }
  292. if (failure.kind === 'output-limit') outputOverflow = true
  293. finish(failure)
  294. return
  295. }
  296. const value = raw.value === undefined ? undefined : decodePtcJsonWire(raw.value)
  297. if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
  298. else finish(undefined, value)
  299. return
  300. }
  301. case 'call': {
  302. if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return }
  303. nextId += 1
  304. const functions = bindings.get(raw.global)?.functions
  305. const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined
  306. if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return }
  307. const args = decodePtcJsonWire(raw.args)
  308. if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return }
  309. if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return }
  310. const id = raw.id
  311. void (async () => {
  312. let reply: unknown
  313. try {
  314. const value = snapshotJsonValue(await fn(args))
  315. if (value === undefined) throw new Error('binding resolution must be lossless JSON')
  316. reply = { type: 'reply', id, ok: true, value: encodePtcJsonWire(value) }
  317. } catch (error: unknown) {
  318. reply = { type: 'reply', id, ok: false, message: messageOf(error) }
  319. } finally {
  320. pending -= 1
  321. pendingBytes -= bytes
  322. }
  323. if (!settled) await transport.send(reply)
  324. })().catch((error: unknown) => { protocolFailure(messageOf(error)) })
  325. return
  326. }
  327. default: protocolFailure('unknown control message')
  328. }
  329. }, (error, kind) => {
  330. if (kind === 'protocol') protocolFailure(messageOf(error))
  331. else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) })
  332. else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
  333. })
  334. channel = transport
  335. void launched.done.then((outcome) => {
  336. // Allow queued control-frame callbacks to run before classifying a command exit.
  337. setImmediate(() => { processFinished(outcome) })
  338. }, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
  339. } catch (error: unknown) {
  340. finish({ kind: error instanceof SandboxUnavailableError ? 'sandbox-unavailable' : parsing ? 'exception' : 'worker-exit', message: messageOf(error) })
  341. }
  342. return await result.promise
  343. }
  344. }
  345. export default NodePtcRuntime