index.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /**
  2. * Worker-thread implementation of the code-execution seam: one fresh Node
  3. * worker per run, executing the model's TypeScript after a host-side
  4. * type-strip, with bindings bridged over the message port. Containment, not
  5. * a security boundary (bash-equivalent trust — see the Code Mode RFC's
  6. * trust-posture section): the worker gets an EMPTY environment, a heap cap,
  7. * and two independent budgets — `computeMs` metered on the worker's
  8. * measured event-loop busy time (a hot loop cannot hide behind a pending
  9. * binding call) and a never-pausing `maxWallMs` ceiling — all funneling
  10. * into `worker.terminate()`, which ends hot synchronous loops too.
  11. *
  12. * @module @deepseek-ai/dsh-code-runtime-worker
  13. */
  14. import { Worker } from 'node:worker_threads'
  15. import { stripTypeScriptTypes } from 'node:module'
  16. import { fileURLToPath } from 'node:url'
  17. import { Context } from 'cordis'
  18. import z from 'schemastery'
  19. import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
  20. import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
  21. import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
  22. import { logTruncationMarker } from './protocol.ts'
  23. import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
  24. export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
  25. export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
  26. /** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
  27. export interface Config {
  28. /**
  29. * Busy-time budget in milliseconds: the run fails with kind `'timeout'`
  30. * once the worker's MEASURED event-loop active time
  31. * (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
  32. * measured busy time — not wall time, not host-side pending-call
  33. * bookkeeping — is what makes the budget both fair (a program awaiting a
  34. * slow tool accrues nothing) and ungameable (a hot loop accrues whether
  35. * or not a decoy dispatch is in flight).
  36. */
  37. computeMs?: number
  38. /**
  39. * Wall-clock ceiling in milliseconds; never pauses for anything. The
  40. * backstop for what busy-time cannot see (a program awaiting a promise
  41. * nobody will resolve).
  42. */
  43. maxWallMs?: number
  44. /** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
  45. maxLogBytes?: number
  46. /**
  47. * Byte cap for the completion value, measured by its real cross-boundary
  48. * size (string bytes, or structured-clone wire size); an oversized or
  49. * non-cloneable value crosses as a capped string rendering.
  50. */
  51. maxValueBytes?: number
  52. /** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
  53. maxOldGenerationSizeMb?: number
  54. }
  55. /** {@link Config} after schemastery fills the defaults (every field present). */
  56. type ResolvedConfig = Required<Config>
  57. /**
  58. * How often the host samples the worker's event-loop utilization for the
  59. * `computeMs` budget. An internal cadence, not config: the only effect of
  60. * the interval is budget-expiry granularity (a run can overshoot by up to
  61. * one interval), and nothing a deployment could tune here improves that
  62. * without burning host CPU.
  63. */
  64. const ELU_POLL_INTERVAL_MS = 25
  65. /** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
  66. const RESERVED_WORDS = new Set([
  67. 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
  68. 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
  69. 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
  70. 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
  71. 'private', 'protected', 'public', 'arguments', 'eval',
  72. ])
  73. /** Valid async-function parameter name (the binding global becomes one). */
  74. const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
  75. /**
  76. * The shell a program is wrapped in for the type-strip, matching the
  77. * grammatical context it will execute in (an async function body, where
  78. * top-level `return` and `await` are legal — a bare module parse would
  79. * reject the `return`). Strip mode is position-preserving (removed syntax
  80. * becomes whitespace, nothing shifts), so the wrapper survives the strip
  81. * byte-identical and the body slices back out with the model's own
  82. * line/column positions intact.
  83. */
  84. const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
  85. /** One in-flight run's host-side state, tracked for disposal. */
  86. interface LiveRun {
  87. worker: Worker
  88. settle(failure: CodeRunFailure): void
  89. finished: Promise<void>
  90. }
  91. /**
  92. * The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable
  93. * directly on this repo's Node range via native type stripping — the file
  94. * is erasable-only with type-only relative imports); the built package
  95. * ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown
  96. * entry) because pkg's VFS Worker hook compiles string-path entries as
  97. * CommonJS.
  98. * The URL *pathname*'s extension says which world this module is in —
  99. * pathname, because dev-time module runners (vitest) may suffix
  100. * `import.meta.url` with a query string; relative resolution drops it. Worker
  101. * receives a filesystem string so pkg's VFS Worker hook can resolve it.
  102. */
  103. /* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
  104. const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url))
  105. /** Render an unknown thrown value as a message, `Error` or not. */
  106. function messageOf(error: unknown): string {
  107. return error instanceof Error ? error.message : String(error)
  108. }
  109. /** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
  110. const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
  111. const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
  112. /**
  113. * Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
  114. * can post anything — `null`, primitives, objects with poisoned fields — so
  115. * the compile-time `WorkerToHost` type means nothing here: everything is
  116. * re-validated and REBUILT field by field (a forged extra field never rides
  117. * along; a non-number call id can never be echoed into a reply). Junk returns
  118. * `undefined` and is dropped — a throw in the host's `message` listener would
  119. * crash the host process.
  120. */
  121. function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
  122. if (typeof raw !== 'object' || raw === null) return undefined
  123. const m = raw as Record<string, unknown>
  124. switch (m.type) {
  125. case 'call': {
  126. if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
  127. return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
  128. }
  129. case 'log': {
  130. const entry = m.entry
  131. if (typeof entry !== 'object' || entry === null) return undefined
  132. const e = entry as Record<string, unknown>
  133. if (typeof e.text !== 'string') return undefined
  134. if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
  135. if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
  136. return {
  137. type: 'log',
  138. entry: {
  139. source: e.source as CodeLogEntry['source'],
  140. ...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
  141. text: e.text,
  142. },
  143. }
  144. }
  145. case 'done': {
  146. if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
  147. const error = m.error
  148. if (typeof error !== 'object' || error === null) return undefined
  149. const message = (error as Record<string, unknown>).message
  150. if (typeof message !== 'string') return undefined
  151. return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
  152. }
  153. default: return undefined
  154. }
  155. }
  156. /**
  157. * Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
  158. * truncation suffix {@link prepareValue} appends, so a value the WORKER
  159. * already capped (byte-exact prefix + this marker) passes through unchanged
  160. * instead of being marked twice.
  161. */
  162. const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
  163. /**
  164. * The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
  165. * the `codeRuntime` service; every cap comes from validated config. See the
  166. * module doc for the containment model and the class JSDoc on the seam for
  167. * the contract this implements (error-as-field, hostile-peer port,
  168. * no cross-run state, dispose to quiescence).
  169. */
  170. export class WorkerCodeRuntime extends CodeRuntime {
  171. static Config: z<Config> = z.object({
  172. computeMs: z.number().default(60_000),
  173. maxWallMs: z.number().default(600_000),
  174. maxLogBytes: z.number().default(65_536),
  175. maxValueBytes: z.number().default(32_768),
  176. maxOldGenerationSizeMb: z.number().default(512),
  177. })
  178. readonly language = 'typescript'
  179. readonly isolation = 'worker-thread'
  180. private readonly config: ResolvedConfig
  181. private readonly live = new Set<LiveRun>()
  182. private disposed = false
  183. constructor(ctx: Context, config: Config) {
  184. super(ctx)
  185. // Schemastery filled the defaults; the cast records that. Positivity is a
  186. // semantic check the schema's plain number type does not carry.
  187. this.config = config as ResolvedConfig
  188. for (const [key, value] of Object.entries(this.config)) {
  189. if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
  190. }
  191. ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
  192. }
  193. /**
  194. * Dispose to quiescence: mark the service unusable, fail every in-flight
  195. * run as aborted, and AWAIT each worker's exit so no worker outlives the
  196. * fiber.
  197. */
  198. private async teardown(): Promise<void> {
  199. this.disposed = true
  200. const runs = [...this.live]
  201. for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
  202. await Promise.all(runs.map(run => run.finished))
  203. }
  204. /**
  205. * Execute one program in a fresh worker. Program outcomes — including a
  206. * type-strip syntax error, which never spawns a worker — resolve with
  207. * `result.error`; the method rejects only for seam misuse (a disposed
  208. * runtime, an invalid binding namespace).
  209. * @param request - the program, its bindings, and the abort signal.
  210. * @returns the run's outcome per the seam contract.
  211. */
  212. async run(request: CodeRunRequest): Promise<CodeRunResult> {
  213. if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal')
  214. const bindings = this.validateBindings(request)
  215. if (request.signal?.aborted) {
  216. return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
  217. }
  218. let code: string
  219. try {
  220. const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
  221. code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
  222. } catch (error: unknown) {
  223. // A program that does not survive the type-strip (syntax error,
  224. // non-erasable syntax like `enum`) is a program failure, reported the
  225. // same way a thrown exception would be — and no worker ever spawns.
  226. return { logs: [], error: { kind: 'exception', message: messageOf(error) } }
  227. }
  228. return await this.execute(request, code, bindings)
  229. }
  230. /** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
  231. private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
  232. const bindings = new Map<string, Record<string, CodeBindingFunction>>()
  233. for (const namespace of request.bindings) {
  234. if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
  235. throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
  236. }
  237. if (namespace.global === 'console' || bindings.has(namespace.global)) {
  238. throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
  239. }
  240. bindings.set(namespace.global, namespace.functions)
  241. }
  242. return bindings
  243. }
  244. /** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
  245. private execute(
  246. request: CodeRunRequest,
  247. code: string,
  248. bindings: Map<string, Record<string, CodeBindingFunction>>,
  249. ): Promise<CodeRunResult> {
  250. const bootData: WorkerBootData = {
  251. code,
  252. namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
  253. maxLogBytes: this.config.maxLogBytes,
  254. maxValueBytes: this.config.maxValueBytes,
  255. }
  256. const worker = new Worker(WORKER_PATH, {
  257. workerData: bootData,
  258. // Model code gets NO ambient environment — stronger than the scrubbed
  259. // env the defensive-patterns rule requires for spawned commands.
  260. env: {},
  261. // Hermetic flags too: without this the worker inherits the host
  262. // process's execArgv (a test runner's or tsx's loader hooks), which a
  263. // bare isolate with an empty environment cannot satisfy. The entry
  264. // needs nothing beyond native type stripping, on this repo's whole
  265. // Node range.
  266. execArgv: [],
  267. resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
  268. // Backstop capture: the bootstrap patches JS-level writes into its own
  269. // ordered buffer, so these pipes normally stay silent; anything that
  270. // still arrives (native-level writes) is appended after the done logs.
  271. stdout: true,
  272. stderr: true,
  273. })
  274. return new Promise<CodeRunResult>((resolve) => {
  275. let settled = false
  276. const answered = new Set<number>()
  277. const logs: CodeLogEntry[] = []
  278. const strayLogs: CodeLogEntry[] = []
  279. // ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
  280. // whatever the path: honest port entries, FORGED port entries (model
  281. // code posting `log` messages directly, bypassing the worker-side
  282. // LogBuffer), and stray pipe bytes. On the first overflow it emits the
  283. // same in-band marker the worker's LogBuffer would and drops the rest,
  284. // so the documented cap is one shared `maxLogBytes` however it is hit.
  285. let logBudget = this.config.maxLogBytes
  286. let logsTruncated = false
  287. const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
  288. if (logsTruncated) return
  289. const cost = Buffer.byteLength(entry.text, 'utf8')
  290. if (cost > logBudget) {
  291. logsTruncated = true
  292. sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
  293. return
  294. }
  295. logBudget -= cost
  296. sink.push(entry)
  297. }
  298. // No settled guard: `finish` snapshots the arrays when it resolves, so
  299. // a chunk flushing after settlement mutates only the discarded buffers,
  300. // and the ledger bounds that growth until the pipes close.
  301. const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
  302. admit({ source, text: chunk.toString('utf8') }, strayLogs)
  303. }
  304. worker.stdout.on('data', captureStray('stdout'))
  305. worker.stderr.on('data', captureStray('stderr'))
  306. // Settlement: exactly one outcome wins; every path funnels through
  307. // here, cleans up the timers/listeners, terminates the worker, and
  308. // resolves only after the worker actually exited (quiescence). Logs
  309. // streamed eagerly before the settlement are kept — a timed-out or
  310. // killed program still shows the model what it printed.
  311. let finishResolve!: () => void
  312. const finished = new Promise<void>((done) => { finishResolve = done })
  313. const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
  314. if (settled) return
  315. settled = true
  316. clearInterval(eluTimer)
  317. clearTimeout(wallTimer)
  318. request.signal?.removeEventListener('abort', onAbort)
  319. this.live.delete(live)
  320. void worker.terminate().then(() => {
  321. finishResolve()
  322. resolve({ ...result, logs: [...logs, ...strayLogs] })
  323. })
  324. }
  325. const onDone = (message: WorkerToHost): void => {
  326. if (message.type !== 'done') return
  327. // Re-cap the completion value HOST-side: the honest path already
  328. // capped it in the worker (prepareValue there), but a forged done
  329. // message bypasses the bootstrap entirely — without this, model code
  330. // could flood the host past maxValueBytes. Honest values pass
  331. // unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
  332. finish({
  333. ...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
  334. ...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
  335. })
  336. }
  337. const onCall = (message: WorkerToHost): void => {
  338. if (message.type !== 'call' || settled) return
  339. // Hostile-peer rules: a duplicate id is ignored, an unknown name is
  340. // answered with a failure, and a binding throw/reject becomes the
  341. // program-side rejection — contained here, never a host crash.
  342. if (answered.has(message.id)) return
  343. answered.add(message.id)
  344. const reply = (payload: ReplyMessage): void => {
  345. if (settled) return
  346. try {
  347. worker.postMessage(payload)
  348. } catch {
  349. // The reply value failed structured clone; renegotiate as an error
  350. // reply, which is always clone-plain. Nothing else throws here.
  351. worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
  352. }
  353. }
  354. const record = bindings.get(message.global)
  355. // Own-property lookup only: a forged name like 'constructor' or
  356. // 'hasOwnProperty' must not walk the record's prototype chain and
  357. // reach a callable the consumer never declared.
  358. const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
  359. if (typeof fn !== 'function') {
  360. reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
  361. return
  362. }
  363. void (async () => {
  364. try {
  365. reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
  366. } catch (error: unknown) {
  367. reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
  368. }
  369. })()
  370. }
  371. worker.on('message', (raw: unknown) => {
  372. // Parse before touching: the peer can post ANY shape, and a throw in
  373. // this listener would crash the host process. Junk drops silently.
  374. const message = parseWorkerMessage(raw)
  375. if (!message) return
  376. if (message.type === 'log' && !settled) admit(message.entry, logs)
  377. onCall(message)
  378. onDone(message)
  379. })
  380. worker.on('error', (error: Error) => {
  381. finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
  382. })
  383. worker.on('exit', (exitCode: number) => {
  384. finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
  385. })
  386. // The compute budget reads the worker's own measured busy time, so a
  387. // hot loop expires it no matter what dispatches are in flight, while a
  388. // program idling on a slow binding accrues nothing.
  389. const eluTimer = setInterval(() => {
  390. const elu = worker.performance.eventLoopUtilization()
  391. if (elu.active > this.config.computeMs) {
  392. finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
  393. }
  394. }, ELU_POLL_INTERVAL_MS)
  395. const wallTimer = setTimeout(() => {
  396. finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
  397. }, this.config.maxWallMs)
  398. const onAbort = (): void => {
  399. finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
  400. }
  401. request.signal?.addEventListener('abort', onAbort, { once: true })
  402. const live: LiveRun = {
  403. worker,
  404. finished,
  405. settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
  406. }
  407. this.live.add(live)
  408. })
  409. }
  410. }
  411. export default WorkerCodeRuntime