feat(code-runtime): execute Node programs through confined processes
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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 })
|
||||
Reference in new issue
Block a user