mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(code-mode): generalize failures and bound diagnostics
This commit is contained in:
@@ -21,9 +21,10 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
- **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
|
||||
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
|
||||
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
|
||||
- **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns.
|
||||
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, flatten into a bounded-depth pre-order wire value for structured clone, and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port; settlement therefore continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
|
||||
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import { jsonValueBytesUpTo } from './output-json.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
|
||||
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
@@ -27,25 +27,28 @@ export interface PatchableStream {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered text capture under one shared byte budget, delivered to a sink as
|
||||
* each item lands (the real sink streams text over the port eagerly, so
|
||||
* captured output survives a mid-run termination). Once the budget is
|
||||
* exhausted it emits the fitting prefix and reports the limit once; the host
|
||||
* turns that condition into an explicit `output-limit` run failure.
|
||||
* Ordered text capture under the shared outer JSON-byte budget, delivered to
|
||||
* a sink as each item lands (the real sink streams text over the port eagerly,
|
||||
* so captured output survives a mid-run termination). It includes the log
|
||||
* array syntax and string escaping in its accounting. Once exhausted it emits
|
||||
* the fitting prefix and reports the limit once; the host turns that condition
|
||||
* into an explicit `output-limit` run failure.
|
||||
*/
|
||||
export class LogBuffer {
|
||||
private remaining: number
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
private truncated = false
|
||||
// Explicit fields, not constructor parameter properties: this module loads
|
||||
// under Node's native strip-only mode, which rejects non-erasable syntax —
|
||||
// and parameter properties are non-erasable.
|
||||
private readonly sink: (text: string) => void
|
||||
private readonly onLimit: () => void
|
||||
private readonly maxBytes: number
|
||||
|
||||
constructor(maxBytes: number, sink: (text: string) => void, onLimit: () => void = () => {}) {
|
||||
this.maxBytes = maxBytes
|
||||
this.sink = sink
|
||||
this.onLimit = onLimit
|
||||
this.remaining = maxBytes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,18 +57,32 @@ export class LogBuffer {
|
||||
*/
|
||||
push(text: string): void {
|
||||
if (this.truncated) return
|
||||
const cost = Buffer.byteLength(text, 'utf8')
|
||||
if (cost > this.remaining) {
|
||||
const separatorBytes = this.entries > 0 ? 1 : 0
|
||||
const availableBytes = this.maxBytes - this.bytes - separatorBytes
|
||||
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
|
||||
if (stringBytes === undefined) {
|
||||
this.truncated = true
|
||||
const prefix = truncateUtf8Bytes(text, this.remaining)
|
||||
if (prefix.length > 0) this.sink(prefix)
|
||||
this.remaining = 0
|
||||
const prefix = truncateJsonStringBytes(text, availableBytes)
|
||||
if (prefix.length > 0) {
|
||||
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
|
||||
/* v8 ignore next -- truncateJsonStringBytes guarantees the returned prefix fits. */
|
||||
if (prefixBytes === undefined) throw new Error('worker output ledger produced an oversized log prefix')
|
||||
this.bytes += prefixBytes + separatorBytes
|
||||
this.entries += 1
|
||||
this.sink(prefix)
|
||||
}
|
||||
this.onLimit()
|
||||
return
|
||||
}
|
||||
this.remaining -= cost
|
||||
this.bytes += stringBytes + separatorBytes
|
||||
this.entries += 1
|
||||
this.sink(text)
|
||||
}
|
||||
|
||||
/** Remaining exact JSON-byte budget for the completion value or failure message. */
|
||||
remainingOutputBytes(): number {
|
||||
return this.maxBytes - this.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/** The five console methods the shim captures, in the seam's level vocabulary. */
|
||||
@@ -123,38 +140,22 @@ export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): (
|
||||
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
|
||||
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
|
||||
|
||||
/**
|
||||
* The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
|
||||
* a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
|
||||
* caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
|
||||
* than what a multibyte string actually costs across the boundary.
|
||||
* @param text - the string to bound.
|
||||
* @param maxBytes - the UTF-8 byte budget the prefix must fit.
|
||||
* @returns the prefix (all of `text` when it already fits).
|
||||
*/
|
||||
export function truncateUtf8Bytes(text: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
|
||||
let bytes = 0
|
||||
let end = 0
|
||||
for (const char of text) {
|
||||
const cost = Buffer.byteLength(char, 'utf8')
|
||||
if (bytes + cost > maxBytes) break
|
||||
bytes += cost
|
||||
end += char.length
|
||||
}
|
||||
return text.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the program's completion value for the done message. Only lossless
|
||||
* JSON crosses, and an individually oversized value reports `output-limit`;
|
||||
* the host revalidates both and accounts for the combined outer envelope.
|
||||
* JSON crosses, and a value that does not fit the remaining combined outer
|
||||
* budget reports `output-limit`; the host revalidates hostile traffic and
|
||||
* remains authoritative for native pipe writes the worker cannot observe.
|
||||
*
|
||||
* @param value - the program's completion value.
|
||||
* @param maxOutputBytes - the byte cap for the outer result.
|
||||
* @param remainingOutputBytes - exact bytes left after captured logs.
|
||||
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
|
||||
* @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`.
|
||||
*/
|
||||
export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
||||
export function prepareCompletion(
|
||||
value: unknown,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number = remainingOutputBytes,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
if (value === undefined) return {}
|
||||
let snapshot: ReturnType<typeof snapshotCodeJsonValue>
|
||||
try {
|
||||
@@ -163,35 +164,102 @@ export function prepareCompletion(value: unknown, maxOutputBytes: number): Omit<
|
||||
snapshot = undefined
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
return { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } }
|
||||
return prepareFailure(
|
||||
'invalid-output',
|
||||
'program completion must be lossless JSON',
|
||||
remainingOutputBytes,
|
||||
maxOutputBytes,
|
||||
)
|
||||
}
|
||||
if (jsonValueBytesUpTo(snapshot, maxOutputBytes) === undefined) {
|
||||
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
|
||||
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
|
||||
return outputLimit(maxOutputBytes)
|
||||
}
|
||||
return { value: encodeWorkerJson(snapshot) }
|
||||
}
|
||||
|
||||
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
|
||||
function outputLimit(maxOutputBytes: number): Omit<DoneMessage, 'type'> {
|
||||
return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }
|
||||
}
|
||||
|
||||
/** Admit one bounded failure message or replace it with the fixed overflow diagnostic. */
|
||||
function prepareFailure(
|
||||
kind: 'exception' | 'invalid-output',
|
||||
message: string,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes)
|
||||
return { error: { kind, message } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a thrown program value without sending an unbounded stack or
|
||||
* string across the worker port.
|
||||
* @param error - the value thrown by the program.
|
||||
* @param remainingOutputBytes - exact bytes left after captured logs.
|
||||
* @param maxOutputBytes - the configured cap named in an overflow diagnostic.
|
||||
* @returns a bounded exception or fixed output-limit fragment.
|
||||
*/
|
||||
export function prepareException(
|
||||
error: unknown,
|
||||
remainingOutputBytes: number,
|
||||
maxOutputBytes: number = remainingOutputBytes,
|
||||
): Omit<DoneMessage, 'type'> {
|
||||
let message: string
|
||||
try {
|
||||
const detail: unknown = error instanceof Error ? error.stack ?? error.message : error
|
||||
message = typeof detail === 'string' ? detail : String(detail)
|
||||
} catch {
|
||||
message = 'program threw an unrenderable value'
|
||||
}
|
||||
return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes)
|
||||
}
|
||||
|
||||
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
|
||||
export interface PendingCall {
|
||||
resolve(value: unknown): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
|
||||
/** Program-visible typed rejection for a failed member of the `tools` namespace. */
|
||||
export class ToolCallError extends Error {
|
||||
override readonly name = 'ToolCallError'
|
||||
readonly toolName: string
|
||||
/** Constructor shape for one program-visible binding rejection class. */
|
||||
export type BindingErrorConstructor = new (memberName: string, message: string) => Error
|
||||
|
||||
constructor(toolName: string, message: string) {
|
||||
super(message)
|
||||
this.toolName = toolName
|
||||
/**
|
||||
* Materialize the real error constructor declared by one namespace.
|
||||
* @param descriptor - program-global class name and member-name property.
|
||||
* @returns the constructor injected into the program and used for rejections.
|
||||
*/
|
||||
export function makeBindingErrorClass(
|
||||
descriptor: { name: string; memberNameProperty: string },
|
||||
): BindingErrorConstructor {
|
||||
return class BindingCallError extends Error {
|
||||
constructor(memberName: string, message: string) {
|
||||
super(message)
|
||||
Object.defineProperty(this, 'name', { enumerable: true, value: descriptor.name })
|
||||
Object.defineProperty(this, descriptor.memberNameProperty, { enumerable: true, value: memberName })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the namespace-specific rejection for one lossy binding argument. */
|
||||
function bindingArgumentFailure(global: string, name: string): Error {
|
||||
const message = 'binding arguments must be lossless JSON'
|
||||
return global === 'tools' ? new ToolCallError(name, message) : new Error(message)
|
||||
/** Create the namespace-specific rejection for one failed binding call. */
|
||||
function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberName: string, message: string): Error {
|
||||
return errorClass ? new errorClass(memberName, message) : new Error(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build each declared error class once so calls and `instanceof` share constructor identity.
|
||||
* @param data - binding namespace declarations from the boot payload.
|
||||
* @returns constructors keyed by their owning namespace global.
|
||||
*/
|
||||
export function makeBindingErrorClasses(
|
||||
data: Pick<WorkerBootData, 'namespaces'>,
|
||||
): Map<string, BindingErrorConstructor> {
|
||||
const classes = new Map<string, BindingErrorConstructor>()
|
||||
for (const namespace of data.namespaces) {
|
||||
if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass))
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +297,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
|
||||
* @param port - the port binding calls are posted to.
|
||||
* @param pending - the id-keyed map each posted call parks its handles in.
|
||||
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
|
||||
* @param errorClasses - per-namespace constructors shared with program globals.
|
||||
* @returns one namespace object per declaration, in declaration order.
|
||||
*/
|
||||
export function makeNamespaces(
|
||||
@@ -236,8 +305,10 @@ export function makeNamespaces(
|
||||
port: BootstrapPort,
|
||||
pending: Map<number, PendingCall>,
|
||||
nextId: { value: number },
|
||||
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
|
||||
): Record<string, unknown>[] {
|
||||
return data.namespaces.map(({ global, names }) => {
|
||||
const errorClass = errorClasses.get(global)
|
||||
const namespace = Object.create(null) as Record<string, unknown>
|
||||
for (const name of names) {
|
||||
Object.defineProperty(namespace, name, {
|
||||
@@ -249,13 +320,15 @@ export function makeNamespaces(
|
||||
} catch {
|
||||
detached = undefined
|
||||
}
|
||||
if (detached === undefined) return Promise.reject(bindingArgumentFailure(global, name))
|
||||
if (detached === undefined) {
|
||||
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
pending.set(id, {
|
||||
resolve,
|
||||
reject: (error) => {
|
||||
reject(global === 'tools' ? new ToolCallError(name, error.message) : error)
|
||||
reject(bindingFailure(errorClass, name, error.message))
|
||||
},
|
||||
})
|
||||
try {
|
||||
@@ -263,7 +336,7 @@ export function makeNamespaces(
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
const message = `binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`
|
||||
reject(global === 'tools' ? new ToolCallError(name, message) : new Error(message))
|
||||
reject(bindingFailure(errorClass, name, message))
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -298,7 +371,18 @@ export async function runWorkerMain(
|
||||
wireReplies(port, pending)
|
||||
|
||||
const nextId = { value: 1 }
|
||||
const namespaces = makeNamespaces(data, port, pending, nextId)
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
|
||||
const errorClassParameters: string[] = []
|
||||
const errorClassValues: BindingErrorConstructor[] = []
|
||||
for (const namespace of data.namespaces) {
|
||||
if (!namespace.errorClass) continue
|
||||
errorClassParameters.push(namespace.errorClass.name)
|
||||
const errorClass = errorClasses.get(namespace.global)
|
||||
/* v8 ignore next -- makeBindingErrorClasses covers every declaration in the same data. */
|
||||
if (!errorClass) throw new Error(`missing binding error class for ${namespace.global}`)
|
||||
errorClassValues.push(errorClass)
|
||||
}
|
||||
const consoleShim = makeConsoleShim(logs)
|
||||
|
||||
let done: DoneMessage
|
||||
@@ -307,12 +391,22 @@ export async function runWorkerMain(
|
||||
// `AsyncFunction` is not a global. The program body is strict-mode.
|
||||
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
|
||||
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
|
||||
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'ToolCallError', 'console', `'use strict';\n${data.code}`)
|
||||
const value = await fn(...namespaces, ToolCallError, consoleShim)
|
||||
done = { type: 'done', ...prepareCompletion(value, data.maxOutputBytes) }
|
||||
const fn = new AsyncFunction(
|
||||
...data.namespaces.map(namespace => namespace.global),
|
||||
...errorClassParameters,
|
||||
'console',
|
||||
`'use strict';\n${data.code}`,
|
||||
)
|
||||
const value = await fn(...namespaces, ...errorClassValues, consoleShim)
|
||||
done = {
|
||||
type: 'done',
|
||||
...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
done = { type: 'done', error: { kind: 'exception', message } }
|
||||
done = {
|
||||
type: 'done',
|
||||
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
|
||||
}
|
||||
}
|
||||
port.postMessage(done)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
@@ -74,6 +74,9 @@ const RESERVED_WORDS = new Set([
|
||||
/** Valid async-function parameter name (the binding global becomes one). */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
|
||||
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
|
||||
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
|
||||
|
||||
/**
|
||||
* The shell a program is wrapped in for the type-strip, matching the
|
||||
* grammatical context it will execute in (an async function body, where
|
||||
@@ -312,17 +315,33 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
|
||||
}
|
||||
|
||||
/** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
|
||||
private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
|
||||
const bindings = new Map<string, Record<string, CodeBindingFunction>>()
|
||||
/** Reject malformed binding globals or typed-error declarations as seam 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) || RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
if (namespace.global === 'console' || namespace.global === 'ToolCallError' || bindings.has(namespace.global)) {
|
||||
if (namespace.global === 'console' || bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
bindings.set(namespace.global, namespace.functions)
|
||||
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) || RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
|
||||
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
@@ -331,11 +350,15 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
private execute(
|
||||
request: CodeRunRequest,
|
||||
code: string,
|
||||
bindings: Map<string, Record<string, CodeBindingFunction>>,
|
||||
bindings: Map<string, CodeBindingNamespace>,
|
||||
): Promise<CodeRunResult> {
|
||||
const bootData: WorkerBootData = {
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
|
||||
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, {
|
||||
@@ -435,7 +458,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
||||
// this point, so this payload is structured-cloneable by contract.
|
||||
worker.postMessage(payload)
|
||||
}
|
||||
const record = bindings.get(message.global)
|
||||
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.
|
||||
|
||||
@@ -11,8 +11,12 @@ import type { WorkerJsonWire } from './worker-json.ts'
|
||||
export interface WorkerBootData {
|
||||
/** The type-stripped (plain JS) program body. */
|
||||
code: string
|
||||
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
|
||||
namespaces: { global: string; names: string[] }[]
|
||||
/** Binding namespaces to materialize; functions themselves stay host-side. */
|
||||
namespaces: {
|
||||
global: string
|
||||
names: string[]
|
||||
errorClass?: { name: string; memberNameProperty: string }
|
||||
}[]
|
||||
/** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */
|
||||
maxOutputBytes: number
|
||||
}
|
||||
@@ -42,12 +46,12 @@ interface OutputLimitMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker → host: the program settled. `error` carries a program exception
|
||||
* (the only failure the bootstrap itself can report — 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
|
||||
* size-capped and lossless per the bootstrap. Logs are NOT carried here —
|
||||
* they streamed eagerly as {@link LogMessage}s.
|
||||
* Worker → 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
|
||||
* the remaining combined output cap. Logs are NOT carried here — they streamed
|
||||
* eagerly as {@link LogMessage}s.
|
||||
*/
|
||||
export interface DoneMessage {
|
||||
type: 'done'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, runWorkerMain, ToolCallError, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
@@ -60,23 +60,30 @@ async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
}
|
||||
|
||||
const BOOT = { maxOutputBytes: 65_536 }
|
||||
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
|
||||
|
||||
/** One worker declaration for the Code Mode tools namespace. */
|
||||
function toolNamespace(names: string[]) {
|
||||
return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
|
||||
}
|
||||
|
||||
describe('LogBuffer', () => {
|
||||
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
|
||||
const seen: string[] = []
|
||||
let limits = 0
|
||||
const buffer = new LogBuffer(10, text => seen.push(text), () => { limits += 1 })
|
||||
const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
|
||||
buffer.push('12345')
|
||||
buffer.push('123456')
|
||||
buffer.push('dropped')
|
||||
expect(seen).toEqual(['12345', '12345'])
|
||||
expect(seen).toEqual(['12345', '123'])
|
||||
expect(limits).toBe(1)
|
||||
expect(buffer.remainingOutputBytes()).toBe(0)
|
||||
|
||||
const exactlyFull: string[] = []
|
||||
const fullBuffer = new LogBuffer(4, text => exactlyFull.push(text))
|
||||
fullBuffer.push('1234')
|
||||
const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
|
||||
fullBuffer.push('12')
|
||||
fullBuffer.push('no-prefix-fits')
|
||||
expect(exactlyFull).toEqual(['1234'])
|
||||
expect(exactlyFull).toEqual(['12'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,19 +174,32 @@ describe('prepareCompletion', () => {
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the remaining combined budget for invalid-output diagnostics', () => {
|
||||
expect(prepareCompletion(() => 1, 4, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateUtf8Bytes', () => {
|
||||
it('returns a fitting string whole', () => {
|
||||
expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
|
||||
describe('prepareException', () => {
|
||||
it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
|
||||
expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
|
||||
expect(prepareException('boom', 5, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
|
||||
// Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
|
||||
// budget fits exactly one — and never leaves a lone surrogate behind.
|
||||
const cut = truncateUtf8Bytes('😀😀', 5)
|
||||
expect(cut).toBe('😀')
|
||||
expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
|
||||
it('contains a thrown value whose string conversion fails', () => {
|
||||
const thrown = { toString() { throw new Error('cannot render') } }
|
||||
expect(prepareException(thrown, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: 'program threw an unrenderable value' },
|
||||
})
|
||||
|
||||
const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
|
||||
expect(prepareException(strangeStack, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: '42' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,7 +239,16 @@ describe('makeNamespaces', () => {
|
||||
on: () => {},
|
||||
}
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const data = { namespaces: [toolNamespace(['x'])] }
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const ToolCallError = errorClasses.get('tools')
|
||||
const [tools] = makeNamespaces(
|
||||
data,
|
||||
throwingPort,
|
||||
pending,
|
||||
{ value: 1 },
|
||||
errorClasses,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
|
||||
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
|
||||
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
@@ -237,7 +266,7 @@ describe('makeNamespaces', () => {
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const nextId = { value: 1 }
|
||||
const [tools] = makeNamespaces(
|
||||
{ namespaces: [{ global: 'tools', names: ['x'] }] }, port, pending, nextId,
|
||||
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const decorated = [1]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
@@ -267,18 +296,18 @@ describe('makeNamespaces', () => {
|
||||
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
|
||||
expect(denied).toBeInstanceOf(Error)
|
||||
expect(denied).not.toBeInstanceOf(ToolCallError)
|
||||
expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
|
||||
expect(denied).not.toHaveProperty('toolName')
|
||||
|
||||
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
|
||||
expect(invalid).toBeInstanceOf(Error)
|
||||
expect(invalid).not.toBeInstanceOf(ToolCallError)
|
||||
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
|
||||
|
||||
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
|
||||
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
|
||||
expect(cloneFailure).toBeInstanceOf(Error)
|
||||
expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
|
||||
expect(cloneFailure).not.toHaveProperty('toolName')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -306,9 +335,12 @@ describe('runWorkerMain', () => {
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(port.sent).toContainEqual({ type: 'log', text: '1234' })
|
||||
expect(port.logs()).toEqual([])
|
||||
expect(port.sent).toContainEqual({ type: 'output-limit' })
|
||||
expect(port.doneValue()).toBeNull()
|
||||
expect(port.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a thrown program error on the done message', async () => {
|
||||
@@ -331,16 +363,56 @@ describe('runWorkerMain', () => {
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw "x".repeat(1_000_000)',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
|
||||
const stackPort = new FakePort()
|
||||
await runWorkerMain(stackPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw new Error("x".repeat(1_000_000))',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(stackPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
namespaces: [toolNamespace(['x'])],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
|
||||
expect(new ToolCallError('x', 'nope')).toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'nope' })
|
||||
})
|
||||
|
||||
it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
|
||||
namespaces: [{
|
||||
global: 'helpers',
|
||||
names: ['x'],
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
|
||||
})
|
||||
|
||||
it('ignores replies for unknown pending ids', async () => {
|
||||
|
||||
@@ -23,8 +23,15 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WorkerCodeRuntime, {})
|
||||
const result = await ctx.codeRuntime.run({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
|
||||
bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
functions: {
|
||||
double: async args => args.n * 2,
|
||||
fail: async () => { throw new Error('denied') },
|
||||
},
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
})
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
@@ -40,7 +47,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(42)
|
||||
expect(result.value).toEqual({
|
||||
doubled: 42,
|
||||
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' },
|
||||
})
|
||||
expect(result.logs).toContain('halfway 42')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,11 @@ async function setup(config: Config = {}) {
|
||||
|
||||
/** Convenience: one namespace `tools` with the given functions. */
|
||||
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
|
||||
return [{ global: 'tools', functions: functions as Record<string, CodeBindingFunction> }]
|
||||
return [{
|
||||
global: 'tools',
|
||||
functions: functions as Record<string, CodeBindingFunction>,
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}]
|
||||
}
|
||||
|
||||
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
@@ -74,6 +78,33 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
it('materializes a typed rejection from a generic namespace descriptor', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
try { await helpers.fail({}) } catch (error) {
|
||||
return {
|
||||
isTyped: error instanceof HelperCallError,
|
||||
name: error.name,
|
||||
helperName: error.helperName,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
`,
|
||||
bindings: [{
|
||||
global: 'helpers',
|
||||
functions: { fail: async () => { throw new Error('nope') } },
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
})
|
||||
expect(result.value).toEqual({
|
||||
isTyped: true,
|
||||
name: 'HelperCallError',
|
||||
helperName: 'fail',
|
||||
message: 'nope',
|
||||
})
|
||||
})
|
||||
|
||||
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
@@ -304,6 +335,31 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('does not send a giant Error stack across the worker port', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: 'throw new Error("x".repeat(1_000_000))',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a program that awaits its write callback, capturing the chunk', async () => {
|
||||
// Node's write(chunk[, encoding][, callback]) contract: dropping the
|
||||
// callback would leave this promise pending until the wall ceiling and
|
||||
@@ -448,6 +504,22 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('re-caps an oversized forged done value at the host boundary', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||
const result = await runtime.run({
|
||||
@@ -634,13 +706,12 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
})
|
||||
|
||||
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, reserved injected globals)', async () => {
|
||||
it('rejects invalid and duplicate binding globals loudly', async () => {
|
||||
const { runtime } = await setup()
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
['ToolCallError', /duplicate binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
@@ -649,6 +720,32 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
|
||||
})).rejects.toThrow(/duplicate binding global/)
|
||||
|
||||
await expect(runtime.run({
|
||||
program: 'return typeof ToolCallError',
|
||||
bindings: [{ global: 'ToolCallError', functions: {} }],
|
||||
})).resolves.toMatchObject({ value: 'object' })
|
||||
})
|
||||
|
||||
it('rejects malformed or colliding binding error-class declarations', async () => {
|
||||
const { runtime } = await setup()
|
||||
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
|
||||
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
|
||||
global,
|
||||
functions: {},
|
||||
errorClass: { name, memberNameProperty },
|
||||
})
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
namespace('helpers', 'CallError'),
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
|
||||
Reference in New Issue
Block a user