feat(code-runtime): execute Node programs through confined processes

This commit is contained in:
Tianyi Cui committed 2026-09-12 15:03:01 +08:00
1 parent f95f7ec8dc
commit 75ed8da3e0
54 files changed
+1933 -2277

No files matched your search

@@ -0,0 +1,51 @@
/** Validate and capture the binding names available to one program. */
import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingNamespace, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* Reject unusable namespaces before starting a process.
* @param request - Host-owned binding declarations.
* @returns Namespaces keyed by their declared global.
*/
export function validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
// `console` is THIS backend's log-capture slot; the dunder entries exist
// for the Python side — its seeded/wrapped slots plus the `__debug__`
// compile-time constant — refused here too so the namespace list stays
// portable across backends. The seam declaration is the single home for
// why each entry is reserved.
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(namespace.global)}`)
}
if (bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (!descriptor) continue
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(descriptor.name)}`)
}
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
const member = descriptor.memberNameProperty
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
throw new Error(`dsh-code-runtime-node: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
@@ -1,14 +1,12 @@
/**
* Worker-side execution logic, written as plain functions over an injected port so the unit
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
* V8 isolate the coverage provider cannot observe).
* Program evaluation, output capture, and host binding proxies over the process channel.
* @module @deepseek-ai/dsh-code-runtime-node/src/bootstrap
*/
import { inspect } from 'node:util'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import type { DoneMessage, ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
import { decodeCodeJsonWire, encodeCodeJsonWire, snapshotCodeJsonValue } from './json-wire.ts'
const CapturedError = Error
const capturedObjectCreate = Object.create
@@ -24,7 +22,7 @@ function defineBindingErrorField(error: Error, key: string, value: string): void
/** The port API the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
export interface BootstrapPort {
postMessage(message: WorkerToHost): void
postMessage(message: ProgramToHost): void
on(event: 'message', listener: (message: ReplyMessage) => void): void
}
@@ -121,7 +119,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
/**
* Redirect a stream's `write` into the log buffer (the program-visible
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
* `process.stdout`/`process.stderr` in the Node child), so raw writes land in emission order
* alongside console output instead of racing down a pipe. It preserves Node's optional callback
* contract: the callback runs asynchronously after admission, even when the log budget drops
* the write.
@@ -186,7 +184,7 @@ export function prepareCompletion(
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
return outputLimit(maxOutputBytes)
}
return { value: encodeWorkerJson(snapshot) }
return { value: encodeCodeJsonWire(snapshot) }
}
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
@@ -265,7 +263,7 @@ function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberN
* @returns constructors keyed by their owning namespace global.
*/
export function makeBindingErrorClasses(
data: Pick<WorkerBootData, 'namespaces'>,
data: Pick<ProgramBootData, 'namespaces'>,
): Map<string, BindingErrorConstructor> {
const classes = new Map<string, BindingErrorConstructor>()
for (const namespace of data.namespaces) {
@@ -278,7 +276,7 @@ export function makeBindingErrorClasses(
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
* to an id already settled) is ignored. Shared wiring between
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
* {@link runProgram} and the tests that exercise {@link makeNamespaces}
* standalone.
* @param port - the port whose `message` events carry the replies.
* @param pending - the id-keyed map of unsettled binding calls.
@@ -289,7 +287,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
if (!entry) return
pending.delete(message.id)
if (message.ok) {
const value = decodeWorkerJson(message.value)
const value = decodeCodeJsonWire(message.value)
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
else entry.resolve(value)
} else {
@@ -313,7 +311,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
data: Pick<WorkerBootData, 'namespaces'>,
data: Pick<ProgramBootData, 'namespaces'>,
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
@@ -344,7 +342,7 @@ export function makeNamespaces(
},
})
try {
port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
port.postMessage({ type: 'call', id, global, name, args: encodeCodeJsonWire(detached) })
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
@@ -366,9 +364,9 @@ export function makeNamespaces(
* @param streams - stdout/stderr objects captured as program logs.
* @returns after posting the done message.
*/
export async function runWorkerMain(
export async function runProgram(
port: BootstrapPort,
data: WorkerBootData,
data: ProgramBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
const logs = new LogBuffer(
@@ -0,0 +1,124 @@
/** Length-prefixed JSON transport with bounded input and queued writes. */
import { Buffer } from 'node:buffer'
import type { Duplex } from 'node:stream'
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
import { jsonValueBytesUpTo } from './output-json.ts'
const stringify = JSON.stringify
const parse = JSON.parse
/** One co-shipped process channel; the consumer owns frame validation and terminal outcomes. */
export class JsonChannel {
private readonly stream: Duplex
private readonly maxBytes: number
private readonly receive: (message: unknown, bytes: number) => void
private readonly failure: (error: Error, kind: 'io' | 'protocol') => void
private readonly header = Buffer.alloc(4)
private headerBytes = 0
private payload: Buffer | undefined
private payloadBytes = 0
private queuedBytes = 0
private closed = false
private tail: Promise<void> = Promise.resolve()
constructor(
stream: Duplex,
maxBytes: number,
receive: (message: unknown, bytes: number) => void,
failure: (error: Error, kind: 'io' | 'protocol') => void,
) {
this.stream = stream
this.maxBytes = maxBytes
this.receive = receive
this.failure = failure
stream.on('data', this.onData)
stream.on('error', this.onError)
stream.on('end', this.onEnd)
}
private readonly onError = (error: Error): void => { if (!this.closed) this.failure(error, 'io') }
private readonly onEnd = (): void => { if (!this.closed) this.failure(new Error('control channel ended before the program settled'), 'io') }
private readonly onData = (chunk: Buffer): void => {
if (this.closed) return
try {
let offset = 0
while (offset < chunk.length && !this.closed) {
if (this.payload === undefined) {
const bytes = Math.min(4 - this.headerBytes, chunk.length - offset)
chunk.copy(this.header, this.headerBytes, offset, offset + bytes)
this.headerBytes += bytes
offset += bytes
if (this.headerBytes !== 4) continue
const length = this.header.readUInt32BE(0)
if (length === 0 || length > this.maxBytes) throw new Error(`control frame exceeds ${this.maxBytes} bytes or is empty`)
this.payload = Buffer.allocUnsafe(length)
this.payloadBytes = 0
this.headerBytes = 0
}
const bytes = Math.min(this.payload.length - this.payloadBytes, chunk.length - offset)
chunk.copy(this.payload, this.payloadBytes, offset, offset + bytes)
this.payloadBytes += bytes
offset += bytes
if (this.payloadBytes !== this.payload.length) continue
const frame = this.payload
this.payload = undefined
this.payloadBytes = 0
this.receive(parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)), frame.length)
}
} catch (error: unknown) {
this.failure(error instanceof Error ? error : new Error(String(error)), 'protocol')
}
}
/**
* Queue a bounded frame and honor stream backpressure.
* @param message - JSON-only co-shipped protocol value.
* @returns Resolves when this frame has been written, or rejects after transport failure.
*/
send(message: unknown): Promise<void> {
if (this.closed) return Promise.reject(new Error('control channel is closed'))
const size = jsonValueBytesUpTo(message as CodeJsonValue, this.maxBytes)
if (size === undefined || this.queuedBytes + size > this.maxBytes) {
return Promise.reject(new Error(`control output exceeds ${this.maxBytes} queued bytes`))
}
const body = Buffer.from(stringify(message), 'utf8')
const header = Buffer.alloc(4)
header.writeUInt32BE(body.length)
this.queuedBytes += body.length
const write = async (): Promise<void> => {
if (this.closed) throw new Error('control channel is closed')
await new Promise<void>((resolve, reject) => {
const stop = (error?: Error): void => {
this.stream.off('error', onError)
this.stream.off('close', onClose)
if (error) reject(error)
else resolve()
}
const onError = (error: Error): void => { stop(error) }
const onClose = (): void => { stop(new Error('control channel closed during a write')) }
this.stream.once('error', onError)
this.stream.once('close', onClose)
this.stream.cork()
this.stream.write(header)
this.stream.write(body, (error) => { stop(error ?? undefined) })
this.stream.uncork()
})
}
const result = this.tail.then(write).finally(() => { this.queuedBytes -= body.length })
this.tail = result.catch(() => {})
return result
}
/** Stop reads and close the owned endpoint; pending writes reject on closure. */
close(): void {
if (this.closed) return
this.closed = true
this.payload = undefined
this.stream.off('data', this.onData)
this.stream.off('end', this.onEnd)
this.stream.destroy()
}
/** Wait for accepted writes to finish or fail. */
async drain(): Promise<void> { await this.tail }
}
@@ -1,560 +1,308 @@
/**
* Worker-thread code runtime: a fresh worker runs each host-type-stripped TypeScript program
* and bridges bindings over its message port. This is containment, not a security boundary:
* model code has bash-equivalent trust despite an empty environment, a heap cap, measured
* event-loop busy-time and wall-time budgets, and termination that also stops synchronous loops.
* @module @deepseek-ai/dsh-code-runtime-node
*/
import { Worker } from 'node:worker_threads'
/** Confined Node programs with host-owned bindings, output limits, and managed process cleanup. */
import { stripTypeScriptTypes } from 'node:module'
import type { Readable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { isAbsolute } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult, CodeRunSandbox, CodeRunSpec } from '@deepseek-ai/dsh-code-runtime'
import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-fs'
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
import type { WorkerJsonWire } from './worker-json.ts'
import { validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import { bootstrapArgs } from './launch.ts'
import type { LaunchConfig } from './launch.ts'
import { OutputLedger } from './output-ledger.ts'
import { decodeCodeJsonWire, encodeCodeJsonWire } from './json-wire.ts'
import type { ProgramBootData } from './protocol.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
/**
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
* once the worker's MEASURED event-loop active time
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
* measured busy time — not wall time, not host-side pending-call
* bookkeeping — is what makes the budget both fair (a program awaiting a
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
* or not a decoy dispatch is in flight).
*/
computeMs?: number
/**
* Wall-clock ceiling in milliseconds; never pauses for anything. The
* backstop for what busy-time cannot see (a program awaiting a promise
* nobody will resolve). At most `2_147_483_647` (Node's maximum
* `setTimeout` delay, about 24.9 days): a longer value is rejected at load
* because `setTimeout` would clamp it to 1 ms.
*/
maxWallMs?: number
/**
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
* fixed result-envelope syntax is excluded.
*/
/** Deployment-varying runtime bounds and launch choices. */
export interface Config extends LaunchConfig {
/** Default elapsed deadline, including nested tool and approval waits. */
timeoutMs?: number
/** Maximum elapsed deadline accepted by resolve. */
maxTimeoutMs?: number
/** Combined serialized logs, completion and diagnostic byte cap. */
maxOutputBytes?: number
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
/** Managed process termination and output-drain grace in milliseconds. */
graceMs?: number
}
/** {@link Config} after schemastery fills the defaults (every field present). */
type ResolvedConfig = Required<Config>
type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
interface LiveRun { controller: AbortController; finished: Promise<void> }
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
const STRIP_SUFFIX = '\n}'
/**
* How often the host samples the worker's event-loop utilization for the
* `computeMs` budget. An internal cadence, not config: the only effect of
* the interval is budget-expiry granularity (a run can overshoot by up to
* one interval), and nothing a deployment could tune here improves that
* without burning host CPU.
*/
const ELU_POLL_INTERVAL_MS = 25
function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
const MIN_OUTPUT_BYTES = 4
/**
* The seam's language-portable identifier subset (see
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
* namespace list must be usable against every backend regardless of language.
*/
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
* top-level `return` and `await` are legal — a bare module parse would
* reject the `return`). Strip mode is position-preserving (removed syntax
* becomes whitespace, nothing shifts), so the wrapper survives the strip
* byte-identical and the body slices back out with the model's own
* line/column positions intact.
*/
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
/** One in-flight run's host-side state, tracked for disposal. */
interface LiveRun {
worker: Worker
settle(failure: CodeRunFailure): void
finished: Promise<void>
}
/**
* The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable
* directly on this repo's Node range via native type stripping — the file
* is erasable-only with type-only relative imports); the built package
* ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown
* entry) because pkg's VFS Worker hook compiles string-path entries as
* CommonJS.
* The URL *pathname*'s extension says which world this module is in —
* pathname, because dev-time module runners (vitest) may suffix
* `import.meta.url` with a query string; relative resolution drops it. Worker
* receives a filesystem string so pkg's VFS Worker hook can resolve it.
*/
/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url))
/** Render an unknown thrown value as a message, `Error` or not. */
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */
function waitForPipeDrain(stream: Readable): Promise<void> {
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
return new Promise((resolve) => {
const done = (): void => {
stream.off('end', done)
stream.off('close', done)
stream.off('error', done)
resolve()
}
stream.once('end', done)
stream.once('close', done)
stream.once('error', done)
// Close the event-registration race if termination finished between the
// initial state check and the listeners above.
/* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */
if (stream.readableEnded || stream.destroyed) done()
})
}
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
* the compile-time `WorkerToHost` type means nothing here: everything is
* re-validated and REBUILT field by field (a forged extra field never rides
* along; a non-number call id can never be echoed into a reply). Junk returns
* `undefined` and is dropped — a throw in the host's `message` listener would
* crash the host process.
*/
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const m = raw as Record<string, unknown>
switch (m.type) {
case 'call': {
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args as WorkerJsonWire }
}
case 'log': {
if (typeof m.text !== 'string') return undefined
return { type: 'log', text: m.text }
}
case 'output-limit': return { type: 'output-limit' }
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value as WorkerJsonWire } : {} }
const error = m.error
if (typeof error !== 'object' || error === null) return undefined
const { kind, message } = error as Record<string, unknown>
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
return { type: 'done', error: { kind, message } }
}
default: return undefined
}
}
/** One run's combined outer-output ledger; binding values never enter it. */
class OutputLedger {
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
constructor(private readonly maxBytes: number) {}
/** Admit one exact log entry, or report that the hard cap was crossed. */
admit(text: string, sink: string[]): boolean {
const separatorBytes = this.entries > 0 ? 1 : 0
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
if (stringBytes === undefined) return false
this.bytes += stringBytes + separatorBytes
this.entries += 1
sink.push(text)
return true
}
/** Finalize a successful absent-or-JSON completion against the combined cap. */
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, ...value !== undefined ? { value } : {} }
}
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, error }
}
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
const messageBytes = fullMessage.length + 2
const retained: string[] = []
let retainedBytes = 2
const logBudget = this.maxBytes - messageBytes
for (const text of logs) {
const separatorBytes = retained.length > 0 ? 1 : 0
const availableBytes = logBudget - retainedBytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes !== undefined) {
retained.push(text)
retainedBytes += stringBytes + separatorBytes
continue
}
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
retained.push(prefix)
retainedBytes += prefixBytes + separatorBytes
}
break
}
const availableMessageBytes = this.maxBytes - retainedBytes
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
return { logs: retained, error: { kind: 'output-limit', message } }
}
}
/**
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
* the `codeRuntime` service; every cap comes from validated config. See the
* module doc for the containment model and the Service Definition's class JSDoc for
* the contract this implements (error-as-field, hostile-peer port,
* no cross-run state, dispose to quiescence).
*/
/** Node provider; direct file effects use the same sandbox service as Bash. */
export class NodeCodeRuntime extends CodeRuntime {
static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
static Config: z<Config> = z.object({
computeMs: z.number().default(60_000),
maxWallMs: z.number().default(600_000),
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(67_108_864),
maxOldGenerationSizeMb: z.number().default(512),
maxMessageBytes: z.number().default(134_217_728),
maxPendingCalls: z.number().default(128),
graceMs: z.number().default(3_000),
nodeExecutable: z.string(),
bootstrapPath: z.string(),
})
readonly language = 'typescript'
readonly isolation = 'worker-thread'
readonly isolation = 'process'
private readonly config: ResolvedConfig
private readonly live = new Set<LiveRun>()
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery filled the defaults; the cast records that. Positivity is a
// semantic check the schema's plain number type does not carry.
this.config = config as ResolvedConfig
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
for (const [key, value] of Object.entries(this.config)) {
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-node: config.${key} must be a positive number, got ${String(value)}`)
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`code-runtime-node: ${key} must be positive and finite`)
}
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
throw new Error(`dsh-code-runtime-node: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
if (this.config[key] > MAX_TIMER_DELAY_MS) throw new Error(`code-runtime-node: ${key} exceeds the supported timer range`)
}
// maxWallMs reaches setTimeout, which clamps any delay above
// MAX_TIMER_DELAY_MS to 1 ms; the positivity check above accepts such a
// value, so a 25-day ceiling would time the run out immediately.
if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
throw new Error(`dsh-code-runtime-node: config.maxWallMs must be at most ${MAX_TIMER_DELAY_MS} (Node clamps a longer setTimeout delay to 1ms), got ${String(this.config.maxWallMs)}`)
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < 4) throw new Error('code-runtime-node: maxOutputBytes must be an integer of at least 4')
if (!Number.isSafeInteger(this.config.maxMessageBytes) || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('code-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
if (!Number.isSafeInteger(this.config.maxPendingCalls)) throw new Error('code-runtime-node: maxPendingCalls must be an integer')
if (!Number.isSafeInteger(this.config.maxOldGenerationSizeMb)) throw new Error('code-runtime-node: maxOldGenerationSizeMb must be an integer')
if (this.config.nodeExecutable.length === 0) throw new Error('code-runtime-node: nodeExecutable must be non-empty')
if (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath)) throw new Error('code-runtime-node: bootstrapPath must be absolute')
ctx.effect(() => async () => {
this.disposed = true
const active = [...this.live]
for (const run of active) run.controller.abort('runtime disposed')
await Promise.all(active.map(run => run.finished))
}, 'Node code-runtime cleanup')
}
override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode }
/**
* Resolve an execution under explicit or deployment policy.
* @param request - Program, bindings, optional cwd/deadline, and resolved authority.
* @returns Complete execution inputs with a capped deadline.
*/
resolve(request: CodeRunRequest): CodeRunSpec {
const sandboxPolicy = request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const cwd = request.cwd ?? sandboxPolicy.workspaceRoot
if (!isAbsolute(cwd)) throw new Error('code-runtime-node: cwd must be absolute')
return {
...request,
cwd,
timeoutMs: clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'code-runtime-node: timeoutMs'),
sandboxPolicy,
}
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
}
/**
* Dispose to quiescence: mark the service unusable, fail every in-flight
* run as aborted, and AWAIT each worker's exit so no worker outlives the
* fiber.
* Run a resolved program in a fresh managed and confined Node process.
* @param spec - Inputs returned by resolve; missing authority is caller misuse.
* @returns Output and file-confinement facts after managed cleanup.
*/
private async teardown(): Promise<void> {
this.disposed = true
const runs = [...this.live]
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
await Promise.all(runs.map(run => run.finished))
}
/**
* Execute one program in a fresh worker. Program outcomes — including a
* type-strip syntax error, which never spawns a worker — resolve with
* `result.error`; the method rejects only for Service Definition contract misuse (a disposed
* runtime, an invalid binding namespace).
* @param request - the program, its bindings, and the abort signal.
* @returns the run's outcome per the seam contract.
*/
async run(request: CodeRunRequest): Promise<CodeRunResult> {
if (this.disposed) throw new Error('dsh-code-runtime-node: run() after disposal')
const bindings = this.validateBindings(request)
if (request.signal?.aborted) {
return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) })
}
let code: string
async run(spec: CodeRunSpec): Promise<CodeRunResult> {
if (this.disposed) throw new Error('code-runtime-node: run after disposal')
if (spec.sandboxPolicy === undefined) throw new Error('code-runtime-node: run requires a resolved sandbox policy')
if (!isAbsolute(spec.cwd) || !Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs) throw new Error('code-runtime-node: run requires resolved cwd and timeout')
const bindings = validateBindings(spec)
const controller = new AbortController()
const completion = Promise.withResolvers<void>()
const live = { controller, finished: completion.promise }
this.live.add(live)
try {
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
} catch (error: unknown) {
// A program that does not survive the type-strip (syntax error,
// non-erasable syntax like `enum`) is a program failure, reported the
// same way a thrown exception would be — and no worker ever spawns.
return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) })
return await this.execute(spec, spec.sandboxPolicy, bindings, controller)
} finally {
this.live.delete(live)
completion.resolve()
}
return await this.execute(request, code, bindings)
}
/** Apply the outer-output ledger to failures that occur before a worker owns one. */
private failureBeforeWorker(error: CodeRunFailure): CodeRunResult {
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
}
/** Reject malformed binding globals or typed-error declarations as Service Definition contract misuse. */
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
// `console` is THIS backend's log-capture slot; the dunder entries exist
// for the Python side — its seeded/wrapped slots plus the `__debug__`
// compile-time constant — refused here too so the namespace list stays
// portable across backends. The seam declaration is the single home for
// why each entry is reserved.
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(namespace.global)}`)
}
if (bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-node: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (!descriptor) continue
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(descriptor.name)}`)
}
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-node: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
const member = descriptor.memberNameProperty
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
throw new Error(`dsh-code-runtime-node: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
/** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
private execute(
request: CodeRunRequest,
code: string,
private async execute(
spec: CodeRunSpec,
policy: SandboxExecutionPolicy,
bindings: Map<string, CodeBindingNamespace>,
controller: AbortController,
): Promise<CodeRunResult> {
const bootData: WorkerBootData = {
code,
namespaces: [...bindings].map(([global, namespace]) => ({
global,
names: Object.keys(namespace.functions),
...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
})),
maxOutputBytes: this.config.maxOutputBytes,
}
const worker = new Worker(WORKER_PATH, {
workerData: bootData,
// Model code gets NO ambient environment — stronger than the scrubbed
// env the defensive-patterns rule requires for spawned commands.
env: {},
// Hermetic flags too: without this the worker inherits the host process's execArgv (a
// test runner's or tsx's loader hooks), which a bare isolate with an empty environment
// cannot satisfy.
execArgv: [],
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
// Backstop capture: the bootstrap patches JS-level writes into its own
// ordered buffer, so these pipes normally stay silent; anything that
// still arrives (native-level writes) is appended after the done logs.
stdout: true,
stderr: true,
})
return new Promise<CodeRunResult>((resolve) => {
let settled = false
const answered = new Set<number>()
const logs: string[] = []
const strayLogs: string[] = []
const output = new OutputLedger(this.config.maxOutputBytes)
let terminalOverride: CodeRunResult | undefined
// Pipe and message-port delivery are independent. Continue bounded pipe
// capture after a terminal message while worker termination drains bytes
// that were already queued; `finish` materializes the result only after
// termination completes.
const captureStray = (chunk: Buffer): void => {
/* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */
if (terminalOverride !== undefined) return
const text = chunk.toString('utf8')
if (!output.admit(text, strayLogs)) {
const limited = output.limit([...logs, ...strayLogs, text])
terminalOverride = limited
finish(limited)
}
}
worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray)
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
// logs captured before timeout, abort, or failure remain in the result.
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => {
if (settled) return
settled = true
clearInterval(eluTimer)
clearTimeout(wallTimer)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
// Let the poll phase deliver pipe bytes already queued independently
// of the terminal port message before termination closes the streams.
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
const stdoutDrained = waitForPipeDrain(worker.stdout)
const stderrDrained = waitForPipeDrain(worker.stderr)
await Promise.all([worker.terminate(), stdoutDrained, stderrDrained])
const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize)
finishResolve()
resolve(result)
})
}
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
if (message.error) {
const error = message.error
finish(() => output.failure([...logs, ...strayLogs], error))
return
}
if (message.value === undefined) {
finish(() => output.success([...logs, ...strayLogs]))
return
}
const value = decodeWorkerJson(message.value)
if (value === undefined) {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
} else {
finish(() => output.success([...logs, ...strayLogs], value))
}
}
const onCall = (message: WorkerToHost): void => {
if (message.type !== 'call' || settled) return
// Hostile-peer rules: a duplicate id is ignored, an unknown name is
// answered with a failure, and a binding throw/reject becomes the
// program-side rejection — contained here, never a host crash.
if (answered.has(message.id)) return
answered.add(message.id)
const reply = (payload: ReplyMessage): void => {
if (settled) return
// Canonical resolutions were snapshotted as lossless JSON before
// this point, so this payload is structured-cloneable by contract.
worker.postMessage(payload)
}
const record = bindings.get(message.global)?.functions
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
if (typeof fn !== 'function') {
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
return
}
const args = decodeWorkerJson(message.args)
if (args === undefined) {
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
return
}
void (async () => {
const output = new OutputLedger(this.config.maxOutputBytes)
const logs: string[] = []
const sandbox: CodeRunSandbox = { mode: policy.mode, denied: false }
const result = Promise.withResolvers<CodeRunResult>()
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
let handle: SubprocessHandle | undefined
let channel: JsonChannel | undefined
let confined: ConfinedArgv | undefined
let settled = false
let timedOut = false
let outputOverflow = false
let stderr = ''
let parsing = true
const wallTimer = setTimeout(() => { timedOut = true; controller.abort('execution deadline reached') }, spec.timeoutMs)
const finish = (failure?: CodeRunFailure, value?: CodeJsonValue): void => {
if (settled) return
settled = true
clearTimeout(wallTimer)
signal.removeEventListener('abort', onAbort)
channel?.close()
void (async () => {
if (handle !== undefined) {
handle.terminate()
try {
const resolved = await fn(args)
let value: CodeJsonValue | undefined
try {
value = snapshotJsonValue(resolved)
} catch {
value = undefined
}
if (value === undefined) {
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
} else {
reply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
}
await Promise.all([handle.done.catch(() => {}), handle.waitForExit()])
} catch (error: unknown) {
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
failure = { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(error)}` }
} finally {
handle.stdout?.destroy()
handle.stderr?.destroy()
}
})()
}
const outcome = outputOverflow ? output.limit(logs)
: failure === undefined ? output.success(logs, value) : output.failure(logs, failure)
result.resolve({ ...outcome, sandbox: { ...sandbox } })
})()
}
const onAbort = (): void => {
finish(timedOut
? { kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms)` }
: { kind: 'abort', message: messageOf(signal.reason) })
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
try {
if (settled) return await result.promise
const stripped = stripTypeScriptTypes(STRIP_PREFIX + spec.program + STRIP_SUFFIX)
parsing = false
const data: ProgramBootData = {
code: stripped.slice(STRIP_PREFIX.length, stripped.length - STRIP_SUFFIX.length),
namespaces: [...bindings.values()].map(binding => ({
global: binding.global,
names: Object.keys(binding.functions),
...binding.errorClass ? { errorClass: binding.errorClass } : {},
})),
maxOutputBytes: this.config.maxOutputBytes,
}
worker.on('message', (raw: unknown) => {
// Parse before touching: the peer can post ANY shape, and a throw in
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
const limited = output.limit([...logs, ...strayLogs, message.text])
finish(limited)
const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, signal)
if (settled) return await result.promise
const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
confined = policy.mode === 'danger-full-access' ? undefined : this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode })
if (confined !== undefined) sandbox.enforcement = confined.enforcement
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env).map(key => [key, undefined]))
if ('pkg' in process && this.config.bootstrapPath === undefined) env.DSH_CODE_RUNTIME_NODE = '1'
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 })
const launched = handle
if (launched.control === undefined) throw new Error('subprocess provider did not supply the requested control channel')
const admit = (text: string): void => {
if (outputOverflow || text.length === 0) return
if (!output.admit(text, logs)) {
outputOverflow = true
finish({ kind: 'output-limit', message: `outer output exceeded ${this.config.maxOutputBytes} bytes` })
}
}
const stdoutDecoder = new StringDecoder('utf8')
const stderrDecoder = new StringDecoder('utf8')
handle.stdout?.on('data', (chunk: Buffer) => { admit(stdoutDecoder.write(chunk)) })
handle.stdout?.on('end', () => { admit(stdoutDecoder.end()) })
handle.stderr?.on('data', (chunk: Buffer) => {
const text = stderrDecoder.write(chunk)
stderr = (stderr + text).slice(-this.config.maxOutputBytes)
admit(text)
})
handle.stderr?.on('end', () => { admit(stderrDecoder.end()) })
let ready = false
let nextId = 1
let pending = 0
let pendingBytes = 0
const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) }
const processFinished = (outcome: SubprocessOutcome): void => {
if (settled) return
finish((confined !== undefined && classifyRunnerFailure(outcome.exitCode, stderr, confined.runnerFailureRules) !== undefined)
? { kind: 'sandbox-unavailable', message: stderr }
: { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` })
}
const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => {
if (settled) return
if (!record(raw)) { protocolFailure('invalid control frame'); return }
if (!ready) {
if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return }
ready = true
void transport.send({ type: 'boot', data }).catch((error) => { protocolFailure(messageOf(error)) })
return
}
if (message.type === 'output-limit' && !settled) {
const limited = output.limit([...logs, ...strayLogs])
finish(limited)
return
switch (raw.type) {
case 'log':
if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return }
admit(raw.text)
return
case 'output-limit': outputOverflow = true; finish(); return
case 'done': {
if (raw.error !== undefined) {
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 }
const failure = { kind: raw.error.kind, message: raw.error.message } as CodeRunFailure
if (confined !== undefined) {
sandbox.denied = confined.denialSignatures.some(signature =>
failure.message.toLowerCase().includes(signature.toLowerCase()))
}
if (failure.kind === 'output-limit') outputOverflow = true
finish(failure)
return
}
const value = raw.value === undefined ? undefined : decodeCodeJsonWire(raw.value)
if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
else finish(undefined, value)
return
}
case 'call': {
if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return }
nextId += 1
const functions = bindings.get(raw.global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined
if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return }
const args = decodeCodeJsonWire(raw.args)
if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return }
if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return }
const id = raw.id as number
void (async () => {
let reply: unknown
try {
const value = snapshotJsonValue(await fn(args))
if (value === undefined) throw new Error('binding resolution must be lossless JSON')
reply = { type: 'reply', id, ok: true, value: encodeCodeJsonWire(value) }
} catch (error: unknown) {
reply = { type: 'reply', id, ok: false, message: messageOf(error) }
} finally {
pending -= 1
pendingBytes -= bytes
}
if (!settled) await transport.send(reply)
})().catch((error) => { protocolFailure(messageOf(error)) })
return
}
default: protocolFailure('unknown control message')
}
onCall(message)
onDone(message)
}, (error, kind) => {
if (kind === 'protocol') protocolFailure(messageOf(error))
else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) })
else void launched.done.then(processFinished, (failure) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
})
worker.on('error', (error: Error) => {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
})
worker.on('exit', (exitCode: number) => {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
})
// The compute budget reads the worker's own measured busy time, so a
// hot loop expires it no matter what dispatches are in flight, while a
// program idling on a slow binding accrues nothing.
const eluTimer = setInterval(() => {
const elu = worker.performance.eventLoopUtilization()
if (elu.active > this.config.computeMs) {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
}
}, ELU_POLL_INTERVAL_MS)
const wallTimer = setTimeout(() => {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
}, this.config.maxWallMs)
const onAbort = (): void => {
finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
}
request.signal?.addEventListener('abort', onAbort, { once: true })
const live: LiveRun = {
worker,
finished,
settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) },
}
this.live.add(live)
})
channel = transport
void launched.done.then((outcome) => { setImmediate(() => { processFinished(outcome) }) }, (error) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
} catch (error: unknown) {
finish({ kind: error instanceof SandboxUnavailableError ? 'sandbox-unavailable' : parsing ? 'exception' : 'worker-exit', message: messageOf(error) })
}
return await result.promise
}
}
@@ -1,6 +1,6 @@
/**
* Lossless-JSON snapshots for the dependency-free source worker closure.
* @module @deepseek-ai/dsh-code-runtime-node/worker-json
* @module @deepseek-ai/dsh-code-runtime-node/json-wire
*/
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
@@ -249,15 +249,15 @@ type WorkerJsonToken = null | boolean | number | string | ArrayWireToken | Objec
* markers and scalar leaves share one flat token array, so `worker_threads`
* never has to structured-clone the value's application nesting.
*/
export type WorkerJsonWire = WorkerJsonToken[]
export type CodeJsonWire = WorkerJsonToken[]
/**
* Flatten one validated JSON value for the worker-thread message port.
* @param value - the lossless JSON value to transport.
* @returns a pre-order token stream whose own nesting is bounded.
*/
export function encodeWorkerJson(value: CodeJsonValue): WorkerJsonWire {
const wire: WorkerJsonWire = []
export function encodeCodeJsonWire(value: CodeJsonValue): CodeJsonWire {
const wire: CodeJsonWire = []
const pending: CodeJsonValue[] = [value]
for (let current = takeLast(pending); current !== undefined; current = takeLast(pending)) {
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
@@ -346,7 +346,7 @@ function containerToken(value: object): ArrayWireToken | ObjectWireToken | undef
* @param input - untrusted message-port payload.
* @returns the detached JSON value, or `undefined` when the wire is invalid.
*/
export function decodeWorkerJson(input: unknown): CodeJsonValue | undefined {
export function decodeCodeJsonWire(input: unknown): CodeJsonValue | undefined {
try {
if (!intrinsicArrayIsArray(input) || !isDenseArray(input) || input.length === 0) return undefined
const wire = input as unknown[]
@@ -0,0 +1,37 @@
/** Select source or built bootstrap assets in the mounted execution world. */
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { FileSystem } from '@deepseek-ai/dsh-fs'
/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
export interface LaunchConfig {
/** Executable in the subprocess world; defaults to the current Node executable. */
nodeExecutable?: string
/** Absolute preinstalled built bootstrap in the execution world. */
bootstrapPath?: string
}
/**
* Select explicit arguments without inheriting host loader or inspector flags.
* @param fs - Filesystem mapping host bootstrap assets into the process world.
* @param config - Optional preinstalled built bootstrap.
* @param maxMessageBytes - Validated frame and queued-write limit.
* @returns Arguments following the resolved Node executable.
*/
export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBytes: number): string[] {
if (config.bootstrapPath !== undefined) return [config.bootstrapPath, String(maxMessageBytes)]
if ('pkg' in process) return [String(maxMessageBytes)]
const mapped = (path: string): string => {
const result = fs.processPathFromHostPath(path)
if (result === undefined) throw new Error(`code runtime bootstrap is unavailable in the subprocess execution world: ${path}`)
return result
}
if (!new URL(import.meta.url).pathname.endsWith('.ts')) {
return [mapped(fileURLToPath(new URL('./process.js', import.meta.url))), String(maxMessageBytes)]
}
const entry = mapped(fileURLToPath(new URL('./process.ts', import.meta.url)))
const subprocess = dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess/package.json')))
const helper = mapped(resolve(subprocess, 'src/control.ts'))
const source = `const {openInheritedControlChannel}=await import(${JSON.stringify(pathToFileURL(helper).href)});const {runNodeMain}=await import(${JSON.stringify(pathToFileURL(entry).href)});await runNodeMain(openInheritedControlChannel(),${maxMessageBytes},process);`
return ['--input-type=module', '--eval', source]
}
@@ -0,0 +1,66 @@
/** Combined byte accounting for retained program output. */
import type { CodeJsonValue, CodeRunFailure, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
/** One run's combined outer-output ledger; binding values never enter it. */
export class OutputLedger {
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
constructor(private readonly maxBytes: number) {}
/** Admit one exact log entry, or report that the hard cap was crossed. */
admit(text: string, sink: string[]): boolean {
const separatorBytes = this.entries > 0 ? 1 : 0
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
if (stringBytes === undefined) return false
this.bytes += stringBytes + separatorBytes
this.entries += 1
sink.push(text)
return true
}
/** Finalize a successful absent-or-JSON completion against the combined cap. */
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, ...value !== undefined ? { value } : {} }
}
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, error }
}
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
const messageBytes = fullMessage.length + 2
const retained: string[] = []
let retainedBytes = 2
const logBudget = this.maxBytes - messageBytes
for (const text of logs) {
const separatorBytes = retained.length > 0 ? 1 : 0
const availableBytes = logBudget - retainedBytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes !== undefined) {
retained.push(text)
retainedBytes += stringBytes + separatorBytes
continue
}
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
retained.push(prefix)
retainedBytes += prefixBytes + separatorBytes
}
break
}
const availableMessageBytes = this.maxBytes - retainedBytes
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
return { logs: retained, error: { kind: 'output-limit', message } }
}
}
@@ -0,0 +1,5 @@
/** Private built Node-runtime child entry; source execution calls runNodeMain directly. */
import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control'
import { runNodeMain } from './process.ts'
await runNodeMain(openInheritedControlChannel(), Number(process.argv[2]), process)
@@ -0,0 +1,69 @@
/** Node child execution over an inherited control channel; no Harness services run here. */
import type { Duplex } from 'node:stream'
import { JsonChannel } from './channel.ts'
import { runProgram } from './bootstrap.ts'
import type { PatchableStream } from './bootstrap.ts'
import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
/** Process-owned environment and output streams consumed by the child bootstrap. */
export interface ProgramProcess {
env: NodeJS.ProcessEnv
stdout: PatchableStream
stderr: PatchableStream
exitCode: string | number | null | undefined
}
/**
* Run one host-supplied program after the control handshake.
* @param stream - Inherited, already-adopted control endpoint.
* @param maxMessageBytes - Host-validated maximum frame and queued-write bytes.
* @param processState - Environment, output streams and exit status of this Node child.
* @returns After the program has settled and its control output has flushed.
*/
export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise<void> {
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit')
for (const key of Object.keys(processState.env)) Reflect.deleteProperty(processState.env, key)
const boot = Promise.withResolvers<ProgramBootData>()
const listeners: Array<(message: ReplyMessage) => void> = []
let started = false
let failed = false
const channel = new JsonChannel(stream, maxMessageBytes, (raw) => {
if (!started) {
started = true
if (typeof raw !== 'object' || raw === null || (raw as { type?: unknown }).type !== 'boot') {
boot.reject(new Error('expected program boot frame'))
return
}
boot.resolve((raw as { data: ProgramBootData }).data)
return
}
for (const listener of listeners) listener(raw as ReplyMessage)
}, (error) => {
failed = true
boot.reject(error)
channel.close()
processState.exitCode = 1
})
const pending = new Set<Promise<void>>()
const send = (message: ProgramToHost): void => {
const task = channel.send(message).catch(() => {
failed = true
channel.close()
processState.exitCode = 1
}).finally(() => { pending.delete(task) })
pending.add(task)
}
try {
await channel.send({ type: 'ready' })
const data = await boot.promise
await runProgram({
postMessage: send,
on: (_event, listener) => { listeners.push(listener) },
}, data, { stdout: processState.stdout, stderr: processState.stderr })
while (pending.size > 0) await Promise.all(pending)
await channel.drain()
} finally {
channel.close()
if (failed) processState.exitCode = 1
}
}
@@ -1,14 +1,13 @@
/**
* Versionless, structured-clone wire protocol between co-shipped host and worker code. The host
* treats inbound traffic as hostile because model code can forge `parentPort` messages; the
* worker trusts host replies.
* JSON message vocabulary carried by the bounded process channel. The host validates every
* message because model code can write directly to the inherited descriptor.
* @module @deepseek-ai/dsh-code-runtime-node/src/protocol
*/
import type { WorkerJsonWire } from './worker-json.ts'
import type { CodeJsonWire } from './json-wire.ts'
/** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData {
/** What the host hands the program at spawn, after the readiness handshake. */
export interface ProgramBootData {
/** The type-stripped (plain JS) program body. */
code: string
/** Binding namespaces to materialize; functions themselves stay host-side. */
@@ -21,32 +20,32 @@ export interface WorkerBootData {
maxOutputBytes: number
}
/** Worker → host: one bridged binding call. */
/** Program → host: one bridged binding call. */
interface CallMessage {
type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
/** Program-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
/** The namespace global the call targets. */
global: string
/** The function name within the namespace. */
name: string
/** The single argument as a flat lossless-JSON wire value. */
args: WorkerJsonWire
args: CodeJsonWire
}
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
/** Program → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
interface LogMessage {
type: 'log'
text: string
}
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
/** Program → host: program-side capture or completion measurement exceeded the outer cap. */
interface OutputLimitMessage {
type: 'output-limit'
}
/**
* Worker → host: the program settled. `error` carries a program exception,
* Program → host: the program settled. `error` carries a program exception,
* invalid completion, or output overflow (budgets, aborts, and substrate death
* are observed host-side). `value` is present only on a clean completion that
* produced one, as a flat wire value already lossless and admitted against
@@ -55,14 +54,14 @@ interface OutputLimitMessage {
*/
export interface DoneMessage {
type: 'done'
value?: WorkerJsonWire
value?: CodeJsonWire
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
}
/** Every message the worker sends. */
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
/** Every message the program sends. */
export type ProgramToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
/** Host → worker: the answer to one {@link CallMessage}. */
/** Host → program: the answer to one {@link CallMessage}. */
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: WorkerJsonWire }
| { type: 'reply'; id: number; ok: true; value: CodeJsonWire }
| { type: 'reply'; id: number; ok: false; message: string }
@@ -1,14 +0,0 @@
/**
* Spawn-only worker entrypoint over {@link runWorkerMain}. Executable logic stays in
* `bootstrap.ts` for in-process coverage; real-worker tests cover this glue.
* @module @deepseek-ai/dsh-code-runtime-node/src/worker
*/
import { parentPort, workerData } from 'node:worker_threads'
import { runWorkerMain } from './bootstrap.ts'
import type { WorkerBootData } from './protocol.ts'
// A worker always has a parent port; guard loudly rather than run detached.
if (!parentPort) throw new Error('dsh-code-runtime-node: worker entry loaded outside a worker thread')
void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })