chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,573 @@
/**
* Host side of an execution, shared by every place a VM can run.
*
* The host owns everything the script must not influence: the tool table, call records, output,
* the deadline, the caller's abort signal, and the decoding of what the VM reports. The VM itself
* runs somewhere a {@link VmLauncher} decides: a worker thread (`CodemodeSandbox`), the calling
* thread (`InlineCodemodeSandbox`), or another isolate reached over RPC (`RemoteCodemodeSandbox`).
* The two sides only exchange the messages in `protocol.ts`, so the launchers differ in transport
* and termination, not in behavior.
*
* Lifecycle of one execution:
* 1. The constructor arms the deadline and abort listener, then asks the launcher for a channel.
* 2. Messages from the VM arrive through {@link VmEvents.message}; tool calls run here and their
* results go back through {@link VmChannel.post}. Replies produced before the launcher has
* returned its channel are queued and flushed when it arrives.
* 3. The first terminal event wins: `done`, `crash`, a launcher failure, the deadline, or an abort.
* `finish()` aborts pending tool calls, asks the channel to stop, and resolves the result once
* that request completes. A worker or remote VM may exit later. Later events are ignored.
*
* The launcher, not this class, is responsible for actually stopping a running VM: a worker is
* terminated, an in-thread VM is interrupted and disposed, and a remote VM is told to stop at its
* next exchange.
*/
import { toCodemodeIdentifier } from "../identifier.ts";
import type {
CodemodeCall,
CodemodeCallStatus,
CodemodeDeclaredGlobal,
CodemodeDeclaredTool,
CodemodeDuring,
CodemodeError,
CodemodeExecuteOptions,
CodemodeOutcome,
CodemodeOutputItem,
CodemodeResult,
CodemodeRun,
CodemodeRunOptions,
CodemodeSandboxBaseOptions,
CodemodeStoreWrites,
CodemodeTool,
} from "../types.ts";
import {
type HostToWorkerMessage,
isWorkerToHostMessage,
type VmStartData,
type WorkerToHostMessage,
} from "./protocol.ts";
const DEFAULT_TIMEOUT_MS = 300_000;
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const RESERVED_GLOBALS: ReadonlySet<string> = new Set([
"tools",
"ALL_TOOLS",
"console",
"text",
"image",
"exit",
"globalThis",
"store",
"load",
]);
/** Callbacks a launcher uses to report what its VM does. */
export interface VmEvents {
/** A message from the VM. Validated here, so launchers can pass through anything they receive. */
message(message: unknown): void;
/** The VM failed or stopped outside the bridge, for example a worker exit or a remote error. */
failure(error: CodemodeError): void;
}
/** The host's handle on a running VM. */
export interface VmChannel {
post(message: HostToWorkerMessage): void;
/** Requests VM shutdown. Called once; may resolve before a worker or remote VM actually exits. */
stop(): Promise<void>;
}
/**
* Starts a VM for one execution. Rejections become `sandbox` errors with the rejection's message.
* The VM may start reporting messages before the returned promise settles.
*/
export type VmLauncher = (data: VmStartData, events: VmEvents) => VmChannel | Promise<VmChannel>;
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function serializeStore(store: Readonly<Record<string, unknown>> | undefined): Record<string, string> {
const serialized: Record<string, string> = {};
for (const [key, value] of Object.entries(store ?? {})) {
const json = JSON.stringify(value);
if (json !== undefined) serialized[key] = json;
}
return serialized;
}
/**
* The prelude that serializes values for the host runs in the same VM as the script, so a script
* that patches built-ins (for example `Array.prototype.toJSON`) can make it send malformed data.
* Payloads that do not decode throw this error, and the execution fails as a sandbox error.
*/
class BridgeError extends Error {}
function parseBridgeJson(json: string, what: string): unknown {
try {
return JSON.parse(json);
} catch {
throw new BridgeError(`${what} is not valid JSON`);
}
}
function parseStoreWrites(json: string): CodemodeStoreWrites {
const entries = parseBridgeJson(json, "store writes");
if (!Array.isArray(entries)) throw new BridgeError("store writes are not an array");
const writes: CodemodeStoreWrites = { set: {}, delete: [] };
for (const entry of entries) {
if (
!Array.isArray(entry) ||
typeof entry[0] !== "string" ||
(entry.length === 2 ? typeof entry[1] !== "string" : entry.length !== 1)
) {
throw new BridgeError("store writes contain a malformed entry");
}
const [key, value] = entry as [string, string?];
if (value === undefined) writes.delete.push(key);
else writes.set[key] = parseBridgeJson(value, `store value for ${JSON.stringify(key)}`);
}
return writes;
}
function parseScriptError(json: string): CodemodeError {
const parsed = parseBridgeJson(json, "script error");
if (typeof parsed !== "object" || parsed === null) throw new BridgeError("script error is not an object");
const { name, message, stack } = parsed as Record<string, unknown>;
if (
typeof message !== "string" ||
(name !== undefined && typeof name !== "string") ||
(stack !== undefined && typeof stack !== "string")
) {
throw new BridgeError("script error is malformed");
}
return { kind: "script", name, message, stack };
}
interface PendingCall {
record: CodemodeCall | undefined;
startedAt: number;
controller: AbortController;
}
/** Checks global names: identifiers or `namespace.member`, not shadowing a built-in global or each other. */
function checkGlobals(globals: readonly CodemodeDeclaredGlobal[]): void {
const names = new Set<string>();
const namespaces = new Set<string>();
for (const global of globals) {
const parts = global.name.split(".");
if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part)) || RESERVED_GLOBALS.has(parts[0])) {
throw new Error(`Invalid global name "${global.name}"`);
}
if (names.has(global.name)) throw new Error(`Global "${global.name}" is already registered`);
if (parts.length === 2) namespaces.add(parts[0]);
names.add(global.name);
}
for (const name of namespaces) {
if (names.has(name)) throw new Error(`Global "${name}" conflicts with the namespace "${name}"`);
}
}
/** Callbacks of a host-controlled run: its calls go to the caller, which answers them with `deliver()`. */
type Control = Pick<CodemodeRunOptions, "onStarted" | "onCall" | "onIdle">;
interface ExecutionOptions {
code: string;
tools: readonly CodemodeDeclaredTool[];
globals: readonly CodemodeDeclaredGlobal[];
/** Run the script's calls; unused when `control` is set. */
executors: { tools: ReadonlyMap<string, CodemodeTool>; globals: ReadonlyMap<string, CodemodeTool> };
timeoutMs: number;
signal: AbortSignal | undefined;
memoryLimitBytes: number | undefined;
store: Record<string, string>;
deterministic?: { startedAt: number; seed: number };
control?: Control;
launch: VmLauncher;
}
/** One script run in its own VM. */
class Execution {
readonly promise: Promise<CodemodeResult>;
private resolveResult!: (result: CodemodeResult) => void;
private channel: VmChannel | undefined;
/** Replies produced before the launcher returned the channel. */
private unsent: HostToWorkerMessage[] = [];
private readonly executors: ExecutionOptions["executors"];
private readonly control: Control | undefined;
/** Result numbers `deliver()` used. */
private readonly delivered = new Set<number>();
private readonly signal: AbortSignal | undefined;
private readonly timer: ReturnType<typeof setTimeout> | undefined;
private readonly output: CodemodeOutputItem[] = [];
private readonly calls: CodemodeCall[] = [];
private readonly pending = new Map<number, PendingCall>();
/** Whether the execution has its outcome. */
finished = false;
constructor(options: ExecutionOptions) {
this.promise = new Promise<CodemodeResult>((resolve) => {
this.resolveResult = resolve;
});
this.executors = options.executors;
this.control = options.control;
this.signal = options.signal;
if (Number.isFinite(options.timeoutMs)) {
this.timer = setTimeout(() => {
this.finish({ kind: "timeout", message: `Execution timed out after ${options.timeoutMs} ms` });
}, options.timeoutMs);
}
if (options.signal) {
if (options.signal.aborted) {
this.onAbort();
} else {
options.signal.addEventListener("abort", this.onAbort, { once: true });
}
}
if (this.finished) return;
const data: VmStartData = {
code: options.code,
tools: options.tools.map((tool) => ({
name: tool.name,
jsName: tool.jsName ?? toCodemodeIdentifier(tool.name),
description: tool.description ?? "",
})),
globals: options.globals.map((global) => ({ name: global.name, spread: global.spread === true })),
memoryLimitBytes: options.memoryLimitBytes,
store: options.store,
...(options.deterministic ? { deterministic: options.deterministic } : {}),
};
const events: VmEvents = {
message: (message) => this.receive(message),
failure: (error) => this.finish(error),
};
new Promise<VmChannel>((resolve) => resolve(options.launch(data, events))).then(
(channel) => {
if (this.finished) {
void channel.stop().catch(() => undefined);
return;
}
this.channel = channel;
for (const message of this.unsent.splice(0)) channel.post(message);
},
(error: unknown) => {
this.finish({ kind: "sandbox", message: errorMessage(error) });
},
);
}
/** `CodemodeRun.deliver()`. */
deliver(seq: number, now: number, id: number, outcome: CodemodeOutcome): void {
if (this.finished) return;
if (!Number.isFinite(now)) throw new Error(`The clock value of result ${seq} is not a finite number`);
if (!Number.isInteger(seq) || seq < 1 || this.delivered.has(seq)) {
throw new Error(`Result number ${seq} is not a positive integer used once`);
}
const pending = this.pending.get(id);
if (pending === undefined) throw new Error(`Call ${id} is not waiting for a result`);
if (!outcome.ok && outcome.properties !== undefined) {
const { properties } = outcome;
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
throw new Error(`The properties of result ${seq} are not an object`);
}
}
// Serialized before anything is recorded, so a value JSON cannot hold leaves the call waiting for another answer.
const payload = outcome.ok
? outcome.value === undefined
? undefined
: JSON.stringify(outcome.value)
: outcome.message;
const properties =
!outcome.ok && outcome.properties !== undefined ? JSON.stringify(outcome.properties) : undefined;
this.delivered.add(seq);
this.pending.delete(id);
if (pending.record) {
pending.record.status = outcome.ok ? "ok" : "error";
pending.record.durationMs = performance.now() - pending.startedAt;
}
this.post({
type: "result",
id,
ok: outcome.ok,
payload,
seq,
now,
...(properties === undefined ? {} : { properties }),
});
}
abort(message: string): Promise<CodemodeResult> {
this.finish({ kind: "aborted", message });
return this.promise;
}
private readonly onAbort = (): void => {
const reason: unknown = this.signal?.reason;
this.finish({ kind: "aborted", message: reason instanceof Error ? reason.message : "Execution aborted" });
};
private post(message: HostToWorkerMessage): void {
if (this.channel) this.channel.post(message);
else this.unsent.push(message);
}
/** An exception here would escape into the launcher's transport and leave the execution unsettled. */
private receive(message: unknown): void {
try {
this.handleMessage(message);
} catch (error) {
this.finish({
kind: "sandbox",
message:
error instanceof BridgeError
? `Sandbox bridge broken: ${error.message}. The script may have modified built-ins such as a prototype's toJSON.`
: `Sandbox host failed: ${errorMessage(error)}`,
});
}
}
private handleMessage(message: unknown): void {
if (this.finished) return;
if (!isWorkerToHostMessage(message)) throw new BridgeError("unknown message from the worker");
switch (message.type) {
case "output":
this.output.push(message.item);
break;
case "call":
if (this.pending.has(message.id)) throw new BridgeError(`duplicate call id ${message.id}`);
if (this.control) this.forwardCall(message, this.control);
else void this.handleCall(message);
break;
case "started":
this.control?.onStarted?.(message.fingerprint);
break;
case "idle":
this.control?.onIdle?.(message.seq, message.issued);
break;
case "done":
this.handleDone(message);
break;
case "crash":
this.finish({ kind: "sandbox", message: message.message });
break;
}
}
private handleDone(message: Extract<WorkerToHostMessage, { type: "done" }>): void {
// Decode everything before finish(), which must not throw once it starts.
if (!message.ok) {
this.finish(parseScriptError(message.error), undefined, undefined, message.during);
return;
}
const value = message.value === undefined ? undefined : parseBridgeJson(message.value, "return value");
this.finish(undefined, value, parseStoreWrites(message.writes), message.during);
}
/** A host-controlled run: record the call and hand it to the caller, which answers with `deliver()`. */
private forwardCall(message: Extract<WorkerToHostMessage, { type: "call" }>, control: Control): void {
const { id, name, target } = message;
const args = message.args === undefined ? undefined : parseBridgeJson(message.args, "call arguments");
const record: CodemodeCall | undefined =
target === "tool" ? { name, status: "cancelled", durationMs: 0 } : undefined;
if (record) this.calls.push(record);
this.pending.set(id, { record, startedAt: performance.now(), controller: new AbortController() });
control.onCall({ id, target, name, args });
}
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {
const { id, name } = message;
const isTool = message.target === "tool";
const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;
if (record) this.calls.push(record);
const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };
this.pending.set(id, pending);
let status: CodemodeCallStatus;
let reply: HostToWorkerMessage;
try {
const tool = (isTool ? this.executors.tools : this.executors.globals).get(name);
if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);
const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);
const value = await tool.execute(args, { signal: pending.controller.signal });
reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };
status = "ok";
} catch (error) {
reply = { type: "result", id, ok: false, payload: errorMessage(error) };
status = "error";
}
// Already cancelled by finish(): the record keeps "cancelled" and the
// VM is gone or going.
if (!this.pending.delete(id)) return;
if (record) {
record.status = status;
record.durationMs = performance.now() - pending.startedAt;
}
this.post(reply);
}
private finish(
error: CodemodeError | undefined,
value?: unknown,
writes?: CodemodeStoreWrites,
during?: CodemodeDuring,
): void {
if (this.finished) return;
this.finished = true;
clearTimeout(this.timer);
this.signal?.removeEventListener("abort", this.onAbort);
const now = performance.now();
for (const pending of this.pending.values()) {
if (pending.record) pending.record.durationMs = now - pending.startedAt;
pending.controller.abort();
}
this.pending.clear();
this.unsent = [];
const ended = during === undefined ? {} : { during: { seq: during.seq, issued: during.issued } };
const result: CodemodeResult = error
? { ok: false, error, output: this.output, calls: this.calls, ...ended }
: {
ok: true,
value,
output: this.output,
calls: this.calls,
storeWrites: writes ?? { set: {}, delete: [] },
...ended,
};
if (!this.channel) {
this.resolveResult(result);
return;
}
this.channel
.stop()
.catch(() => undefined)
.then(() => this.resolveResult(result));
}
}
/**
* Tool table, defaults, and in-flight executions; the behavior every sandbox shares. Subclasses
* only choose where the VM runs by passing a {@link VmLauncher}.
*
* The script sees `tools.<name>(args)` for every registered tool, `ALL_TOOLS`, the output helpers
* `text`, `image`, `exit`, and `console.*`, `store`/`load`, and the configured globals; nothing
* else (no timers, `fetch`, `process`, `require`, modules). Each `execute()` gets its own VM; the
* sandbox only holds the tool table and defaults. `close()` aborts in-flight executions.
*/
export class CodemodeSandboxBase {
private readonly toolsByName = new Map<string, CodemodeTool>();
private readonly globalsByName = new Map<string, CodemodeTool>();
private readonly timeoutMs: number;
private readonly memoryLimitBytes: number | undefined;
private readonly launch: VmLauncher;
private readonly running = new Set<Execution>();
private closed = false;
constructor(options: CodemodeSandboxBaseOptions, launch: VmLauncher) {
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.memoryLimitBytes = options.memoryLimitBytes;
this.launch = launch;
for (const tool of options.tools ?? []) this.registerTool(tool);
checkGlobals(options.globals ?? []);
for (const global of options.globals ?? []) this.globalsByName.set(global.name, global);
}
/** Throws if a tool with the same name is already registered. */
registerTool(tool: CodemodeTool): void {
if (this.toolsByName.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
this.toolsByName.set(tool.name, tool);
}
unregisterTool(name: string): boolean {
return this.toolsByName.delete(name);
}
get tools(): CodemodeTool[] {
return [...this.toolsByName.values()];
}
get globals(): CodemodeTool[] {
return [...this.globalsByName.values()];
}
/**
* `code` is an async function body: `return` and top-level `await` work.
* Never rejects for script failures; those come back as `{ ok: false }`.
* The script can use `store(key, value)` and `load(key)` on `options.store`.
*/
execute(code: string, options: CodemodeExecuteOptions = {}): Promise<CodemodeResult> {
if (this.closed) return Promise.reject(new Error("Sandbox is closed"));
const tools = new Map(this.toolsByName);
const execution = new Execution({
code,
tools: [...tools.values()],
globals: [...this.globalsByName.values()],
executors: { tools, globals: this.globalsByName },
timeoutMs: options.timeoutMs ?? this.timeoutMs,
signal: options.signal,
memoryLimitBytes: this.memoryLimitBytes,
store: serializeStore(options.store),
launch: this.launch,
});
this.running.add(execution);
return execution.promise.finally(() => this.running.delete(execution));
}
/**
* Run `code` deterministically, with the caller answering its calls: the same script, start time, seed, tools,
* globals, and store, answered with the same results in the same `seq` order, makes the same calls with the same
* arguments and produces the same output, on any VM with the same fingerprint. A host can therefore replay it
* after a restart. The VM runs in UTC, its clock reads `startedAt` and then the `now` of the result being
* processed, its randomness comes from `seed`, and `WeakRef` and `FinalizationRegistry` do not exist.
*
* The sandbox's own tools and globals are not used: the run declares its own, and every call goes to
* `options.onCall`. Never rejects for script failures; abort through `options.signal` to end the run with an
* error the script cannot catch.
*/
start(code: string, options: CodemodeRunOptions): CodemodeRun {
if (this.closed) throw new Error("Sandbox is closed");
if (!Number.isFinite(options.startedAt) || options.startedAt < 0) {
throw new Error("startedAt must be a non-negative number of milliseconds since the epoch");
}
if (!Number.isInteger(options.seed) || options.seed < 0 || options.seed > 0xffffffff) {
throw new Error("seed must be a 32-bit unsigned integer");
}
const tools = options.tools ?? [];
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) throw new Error("Tool names must be unique");
const scriptNames = tools.map((tool) => tool.jsName ?? toCodemodeIdentifier(tool.name));
if (new Set(scriptNames).size !== tools.length) throw new Error("Tool script names must be unique");
const globals = options.globals ?? [];
checkGlobals(globals);
const execution = new Execution({
code,
tools,
globals,
executors: { tools: new Map(), globals: new Map() },
timeoutMs: options.timeoutMs ?? Number.POSITIVE_INFINITY,
signal: options.signal,
memoryLimitBytes: this.memoryLimitBytes,
store: serializeStore(options.store),
deterministic: { startedAt: options.startedAt, seed: options.seed },
control: { onStarted: options.onStarted, onCall: options.onCall, onIdle: options.onIdle },
launch: this.launch,
});
this.running.add(execution);
const result = execution.promise.finally(() => this.running.delete(execution));
return {
result,
deliver: (seq, now, id, outcome) => execution.deliver(seq, now, id, outcome),
get finished() {
return execution.finished;
},
};
}
/** Aborts in-flight executions (they resolve with `kind: "aborted"`) and rejects new ones. */
async close(): Promise<void> {
this.closed = true;
await Promise.all([...this.running].map((execution) => execution.abort("Sandbox closed")));
}
}

View File

@@ -0,0 +1,88 @@
import { Worker } from "node:worker_threads";
import type { CodemodeSandboxOptions } from "../types.ts";
import { type CodemodeWasmModule, loadQuickJSWasm } from "../wasm.ts";
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
import type { WorkerData } from "./protocol.ts";
function defaultWorkerUrl(): URL {
// `.ts` when running from source (tests, tsx), `.js` from the published dist.
return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
}
/**
* Each execution gets its own worker thread and QuickJS VM. A fresh worker per run keeps
* termination simple: a runaway script, including one that only spins the microtask queue, is
* killed with `terminate()` and cannot poison a later run. Before terminating, the host sets a
* shared interrupt flag that the VM polls, because Bun's `terminate()` cannot stop a thread that
* is spinning in wasm. Stopping requests termination without waiting for exit: some built-ins do
* not poll the flag, so on Bun a worker can outlive the script's result.
*/
function workerLauncher(options: CodemodeSandboxOptions): VmLauncher {
return async (data, events) => {
let module: CodemodeWasmModule;
try {
module = await (options.wasm ?? loadQuickJSWasm());
} catch (error) {
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
}
const interrupt = new SharedArrayBuffer(4);
const workerData: WorkerData = { ...data, wasm: module, interrupt };
const releaseWorker = options.acquireWorker?.();
let worker: Worker;
try {
worker = new Worker(options.workerUrl ?? defaultWorkerUrl(), { workerData });
} catch (error) {
releaseWorker?.();
throw new Error(`Failed to start worker: ${errorMessage(error)}`);
}
if (releaseWorker) worker.once("exit", releaseWorker);
worker.on("message", (message: unknown) => {
// Node 24/26 --watch leaks dependency reports onto the worker channel (nodejs/node#65044).
if (
typeof message === "object" &&
message !== null &&
("watch:import" in message || "watch:require" in message)
)
return;
events.message(message);
});
worker.on("error", (error: unknown) => {
events.failure({
kind: "sandbox",
name: error instanceof Error ? error.name : undefined,
message: errorMessage(error),
});
});
worker.on("exit", (code) => {
events.failure({ kind: "sandbox", message: `Worker exited with code ${code} before the script settled` });
});
const stop = async (): Promise<void> => {
Atomics.store(new Int32Array(interrupt), 0, 1);
// A non-interruptible built-in can delay Bun's terminate() until it returns (#10748).
// Do not hold up the result while that worker exits.
void worker.terminate().catch(() => undefined);
// terminate() refs the worker internally; unref it after requesting termination.
worker.unref();
};
try {
options.onWorkerStarted?.(new Promise<void>((resolve) => worker.once("exit", () => resolve())));
} catch (error) {
void stop();
throw error;
}
return { post: (message) => worker.postMessage(message), stop };
};
}
/**
* Runs JavaScript in a QuickJS VM (a separate wasm instance) inside a worker thread. The worker
* keeps script execution off the host thread: QuickJS runs synchronously, so a spinning script on
* the host thread would block its event loop. Requires `node:worker_threads` (Node or Bun); see
* `InlineCodemodeSandbox` and `RemoteCodemodeSandbox` in `@earendil-works/pi-codemode/portable`
* for other runtimes.
*/
export class CodemodeSandbox extends CodemodeSandboxBase {
constructor(options: CodemodeSandboxOptions = {}) {
super(options, workerLauncher(options));
}
}

View File

@@ -0,0 +1,126 @@
/**
* Runs the VM on the calling thread, for runtimes without worker threads (Cloudflare Workers and
* Durable Objects, browsers) and for the server side of `RemoteCodemodeSandbox`.
*
* Problem: QuickJS runs synchronously. While a script computes, the host's event loop does not
* run, so neither the deadline timer nor an abort signal can fire. On Workers even `Date.now()` is
* frozen during synchronous execution, so the VM cannot check a clock either. Example: with a
* worker thread, `while (true) {}` is stopped by the host's 200 ms timer; on the calling thread
* that timer never gets a chance to run.
*
* Solution: an interrupt budget. QuickJS polls its interrupt handler periodically while a script
* runs; this launcher counts the polls across the whole execution and interrupts the script once
* the count passes the budget. The execution then fails with `kind: "timeout"`. The deadline and
* abort signal still work whenever the script waits on a tool, since the event loop runs then.
*
* The rate of polls depends on the code and the machine. Measured with a tight loop: about 50,000
* per second of CPU on an Apple M-series laptop and about 10,000 per second on Cloudflare Durable
* Objects (October 2026). The default of 100,000 allows roughly 2 to 10 seconds of uninterrupted
* computation; keep it well below the platform's own CPU limit, because hitting that limit resets
* a Durable Object instead of failing one script.
*
* Once the VM reports `done` or `crash`, the result is decided, so the VM is interrupted at the
* next poll instead of running on until the host stops it. Example: a script that keeps printing in
* a `for (;;)` loop after its output limit was reached would otherwise run until the budget ran out
* and be reported as a timeout.
*
* Stack: the VM runs on the caller's stack, which is smaller than a worker thread's (about 1 MB
* on Node's main thread), so QuickJS's stack guard is set to {@link INLINE_MAX_STACK_SIZE}.
*
* Reentrancy: VM callbacks run inside QuickJS. Messages from the VM are delivered to the host in
* a microtask, after the VM has returned, so host code (tool calls, `finish()`, disposal) never
* runs inside a VM callback and never re-enters the VM.
*/
import type { CodemodeSandboxBaseOptions } from "../types.ts";
import type { CodemodeWasmModule } from "../wasm.ts";
import { CodemodeSandboxBase, errorMessage, type VmLauncher } from "./execution.ts";
import { startVm } from "./vm.ts";
export const DEFAULT_INTERRUPT_BUDGET = 100_000;
/** Allows recursion about 1,000 calls deep and trips well before a 1 MB host stack overflows. */
export const INLINE_MAX_STACK_SIZE = 256 * 1024;
export interface InlineCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
/**
* Compiled `quickjs-wasi/quickjs.wasm`. On Cloudflare, import it with Wrangler's `CompiledWasm`
* rule (`@earendil-works/pi-codemode/cloudflare` does this); on Node, `loadQuickJSWasm()`.
*/
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
/**
* Interrupt polls an execution may use before it fails with `kind: "timeout"`. The only limit on
* a script that computes without awaiting a tool. Default: {@link DEFAULT_INTERRUPT_BUDGET}.
*/
interruptBudget?: number;
}
export function inlineLauncher(
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>,
interruptBudget: number = DEFAULT_INTERRUPT_BUDGET,
): VmLauncher {
return async (data, events) => {
let module: CodemodeWasmModule;
try {
module = await wasm;
} catch (error) {
throw new Error(`Failed to load QuickJS: ${errorMessage(error)}`);
}
let stopped = false;
let settled = false;
let polls = 0;
let exhausted = false;
const session = await startVm({
data,
wasm: module,
maxStackSize: INLINE_MAX_STACK_SIZE,
interruptBudget,
shouldInterrupt: () => {
if (stopped || settled || exhausted) return true;
if (++polls <= interruptBudget) return false;
exhausted = true;
return true;
},
post: (message) => {
const terminal = message.type === "done" || message.type === "crash";
// Only the first terminal message counts: a crash caused by interrupting a settled VM is noise.
if (terminal && settled) return;
if (terminal) settled = true;
queueMicrotask(() => {
if (stopped) return;
// The interrupt surfaces as a crash or a failed done; report what caused it instead.
if (exhausted && (message.type === "done" || message.type === "crash")) {
events.failure({
kind: "timeout",
message: `Execution exceeded its interrupt budget of ${interruptBudget}`,
});
return;
}
events.message(message);
});
},
});
return {
post: (message) => {
if (!stopped) session.deliver(message);
},
stop: async () => {
if (stopped) return;
stopped = true;
session.dispose();
},
};
};
}
/**
* Runs each execution's QuickJS VM on the calling thread. Works in any runtime with WebAssembly
* and microtasks; needs no worker threads, files, or Node built-ins. A script that computes
* blocks the caller's thread until it awaits a tool or uses up its interrupt budget, so prefer
* `CodemodeSandbox` where worker threads exist, and `RemoteCodemodeSandbox` to keep scripts off a
* thread that must stay responsive (such as a Durable Object that serves a session).
*/
export class InlineCodemodeSandbox extends CodemodeSandboxBase {
constructor(options: InlineCodemodeSandboxOptions) {
super(options, inlineLauncher(options.wasm, options.interruptBudget));
}
}

View File

@@ -0,0 +1,522 @@
/**
* JavaScript evaluated inside the QuickJS VM before the script runs.
*
* The VM is its own wasm instance, so nothing here guards a realm boundary.
* The prelude keeps the host bridge in a closure so the script cannot call it
* directly, and builds `tools`, `ALL_TOOLS`, the output helpers (`text`, `image`,
* `exit`, `console`), and globals on top of it. Tool arguments and results cross
* as JSON strings and are parsed on this side.
*
* Output helpers: `text(value)` appends a text item (non-strings are
* JSON-stringified), `image(urlOrItem)` appends an image from a base64 `data:` URL, an
* `{ image_url }` object, or an image block `{ type: "image", data, mimeType }`, and `exit()` ends the script
* successfully. `console.*` appends text items marked as console output.
*
* `store(key, value)` and `load(key)` are synchronous: they work on a snapshot of
* JSON text passed in as `storeJson`, and the keys the script wrote are reported
* with a successful "done".
*
* Evaluates to a function `(bridge, toolsJson, globalsJson, storeJson) => { settle, run, stalled }`.
* `settle(id, ok, payload, propertiesJson)` settles call `id`; a rejection's `Error` gets the own properties of the
* `propertiesJson` object, except `name`, `message`, and `stack`.
* `toolsJson` lists `{ name, jsName, description }`: `tools[jsName]` and `tools[name]` call the
* tool, and `ALL_TOOLS` lists `{ name: jsName, description }`.
* `stalled()` reports a script that has not finished while no host call is pending: with no timers
* or I/O in the VM, nothing can ever resume it.
* `globalsJson` lists `{ name, spread }`; `a.b` names are grouped into a frozen `a` object.
* `bridge(kind, a, b, c)` with kind "call" or "global" (id, name, argsJson),
* "output" ("text" or "console", text) or ("image", data, mimeType), or "done" (ok, valueJsonOrErrorJson, writesJson).
*
* Before anything else the prelude freezes the built-ins and makes the built-in globals read-only.
* The prelude shares them with the script, so a script that patched one (for example
* `Array.prototype.toJSON`) could otherwise corrupt what the prelude reports to the host.
*/
export const MAX_STORE_VALUE_CHARS = 256 * 1024;
export const MAX_STORE_TOTAL_CHARS = 1024 * 1024;
/**
* Output one script may produce with `text()`, `image()`, and `console.*`: characters of text and
* base64 image data, and items. The host keeps all output until the script ends, so without a
* limit a script that prints in a loop grows the host's memory until it crashes. The item limit
* covers loops that print empty strings.
*/
export const MAX_OUTPUT_CHARS = 16 * 1024 * 1024;
export const MAX_OUTPUT_ITEMS = 100_000;
const IMAGE_HELPER_EXPECTS =
'image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }';
export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson, storeJson) {
"use strict";
// Freeze every object reachable from the built-in globals, plus the intrinsics that are only
// reachable from instances (iterator and generator prototypes, %TypedArray%).
//
// Freezing alone breaks ordinary code through the "override mistake": a data property that is
// read-only on a prototype cannot be assigned on an instance either, so
// this.name = "MyError" in an Error subclass would throw. Commonly overridden properties
// become accessors whose setter defines an own property on the instance instead.
(function lockdown() {
const OVERRIDABLE = new Set(["constructor", "name", "message", "toString", "toLocaleString", "valueOf", "toJSON"]);
const seen = new Set([globalThis]);
const queue = [];
const add = (value) => {
if ((typeof value === "object" && value !== null) || typeof value === "function") {
if (!seen.has(value)) {
seen.add(value);
queue.push(value);
}
}
};
// Accessors from an object literal have no own prototype object, unlike function expressions,
// whose prototype.constructor would be converted again without end.
function allowOverride(object, key, value, enumerable) {
const { get, set } = Object.getOwnPropertyDescriptor(
{
get accessor() {
return value;
},
set accessor(next) {
if (this === object) {
throw new TypeError("Cannot assign to read only property '" + String(key) + "' of a built-in");
}
if ((typeof this !== "object" || this === null) && typeof this !== "function") return;
Object.defineProperty(this, key, { value: next, writable: true, enumerable: true, configurable: true });
},
},
"accessor",
);
Object.defineProperty(object, key, { get, set, enumerable, configurable: false });
add(get);
add(set);
}
for (const key of Reflect.ownKeys(globalThis)) {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);
add(descriptor.value);
add(descriptor.get);
add(descriptor.set);
if ("value" in descriptor && descriptor.configurable) {
Object.defineProperty(globalThis, key, { writable: false, configurable: false });
}
}
add(Object.getPrototypeOf(function* () {}));
add(Object.getPrototypeOf(async function () {}));
add(Object.getPrototypeOf(async function* () {}));
add(Object.getPrototypeOf(Int8Array));
add(Object.getPrototypeOf([][Symbol.iterator]()));
add(Object.getPrototypeOf(new Map()[Symbol.iterator]()));
add(Object.getPrototypeOf(new Set()[Symbol.iterator]()));
add(Object.getPrototypeOf(""[Symbol.iterator]()));
add(Object.getPrototypeOf(/a/[Symbol.matchAll]("")));
if (typeof Iterator === "function") {
if (typeof Iterator.prototype.map === "function") add(Object.getPrototypeOf([].values().map((x) => x)));
if (typeof Iterator.from === "function") add(Object.getPrototypeOf(Iterator.from({ next() {} })));
}
while (queue.length > 0) {
const object = queue.pop();
add(Object.getPrototypeOf(object));
const descriptors = Object.getOwnPropertyDescriptors(object);
for (const key of Reflect.ownKeys(descriptors)) {
const descriptor = descriptors[key];
if ("value" in descriptor) {
add(descriptor.value);
if (
descriptor.writable &&
descriptor.configurable &&
(object === Object.prototype || OVERRIDABLE.has(key))
) {
allowOverride(object, key, descriptor.value, descriptor.enumerable);
}
} else {
add(descriptor.get);
add(descriptor.set);
}
}
Object.freeze(object);
}
})();
const stringify = JSON.stringify;
const parse = JSON.parse;
const promiseThen = Promise.prototype.then;
const ErrorCtor = Error;
const TypeErrorCtor = TypeError;
const RangeErrorCtor = RangeError;
const pending = new Map();
let nextId = 1;
let finished = false;
// Thrown by exit() to unwind the script after it already reported success.
const EXIT = Object.freeze({});
function done(ok, payload, writes) {
if (finished) return;
finished = true;
bridge("done", ok, payload, writes);
}
function serialize(value) {
return value === undefined ? undefined : stringify(value);
}
// QuickJS stacks list frames only. Prefix "Name: message" like V8 so the
// text reads the same as a Node error, and drop this prelude's frames.
function errorText(error) {
const head = error.message ? error.name + ": " + error.message : String(error.name);
const frames =
typeof error.stack === "string"
? error.stack.split("\\n").filter((line) => line.trim() && !line.includes("codemode-prelude.js"))
: [];
return [head, ...frames].join("\\n");
}
function format(value) {
if (typeof value === "string") return value;
if (value instanceof ErrorCtor) return errorText(value);
try {
const json = stringify(value);
return json === undefined ? String(value) : json;
} catch {
return String(value);
}
}
// The host requires string fields. A script can set an error's name or message to anything, so
// they are coerced, and a value whose coercion throws gets a placeholder.
function describeError(error) {
try {
if (error instanceof ErrorCtor) {
return stringify({ name: String(error.name), message: String(error.message), stack: errorText(error) });
}
return stringify({ message: String(format(error)) });
} catch {
return stringify({ message: "The script threw a value that cannot be described" });
}
}
function caller(kind, name, spread) {
return (...args) =>
new Promise((resolve, reject) => {
let json;
try {
json = serialize(spread ? args : args[0]);
} catch (error) {
reject(error);
return;
}
const id = nextId++;
pending.set(id, { resolve, reject });
bridge(kind, id, name, json);
});
}
const tools = Object.create(null);
const allTools = [];
for (const { name, jsName, description } of parse(toolsJson)) {
const fn = caller("call", name);
// The first tool wins when two names normalize to the same identifier.
if (!(jsName in tools)) {
tools[jsName] = fn;
allTools.push(Object.freeze({ name: jsName, description }));
}
if (!(name in tools)) tools[name] = fn;
}
Object.freeze(tools);
Object.freeze(allTools);
// Reading a member that does not exist throws an error that names the close matches, instead of
// a later "not a function". \`in\` checks still work.
const comparable = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
function guard(target, label, names, hint) {
return new Proxy(target, {
get(object, property, receiver) {
if (typeof property !== "string" || property in object || property in Object.prototype || property === "then" || property === "toJSON") {
return Reflect.get(object, property, receiver);
}
const wanted = comparable(property);
const exact = names.filter((name) => comparable(name) === wanted);
const close = exact.length > 0 ? exact : names.filter((name) => wanted && (comparable(name).includes(wanted) || wanted.includes(comparable(name))));
let message = label + "." + property + " does not exist.";
if (close.length > 0) message += " Did you mean " + close.slice(0, 5).map((name) => label + "." + name).join(", ") + "?";
else if (names.length <= 20) message += " Available: " + names.join(", ") + ".";
if (hint) message += " " + hint;
message += ' Check for a member with "' + property + '" in ' + label + ".";
throw new TypeErrorCtor(message);
},
});
}
const toolsProxy = guard(
tools,
"tools",
allTools.map((tool) => tool.name),
"ALL_TOOLS lists every tool; searchTools(query) finds tools by topic.",
);
const namespaces = new Map();
for (const { name, spread } of parse(globalsJson)) {
const fn = caller("global", name, spread);
const dot = name.indexOf(".");
if (dot === -1) {
Object.defineProperty(globalThis, name, { value: fn, enumerable: true });
continue;
}
const namespace = name.slice(0, dot);
if (!namespaces.has(namespace)) namespaces.set(namespace, Object.create(null));
namespaces.get(namespace)[name.slice(dot + 1)] = fn;
}
for (const [namespace, members] of namespaces) {
Object.freeze(members);
const value = guard(members, namespace, Object.keys(members));
Object.defineProperty(globalThis, namespace, { value, enumerable: true });
}
// key -> JSON text. Sizes count key and JSON characters.
const stored = new Map(Object.entries(parse(storeJson)));
const writes = new Map();
let storedChars = 0;
for (const [key, json] of stored) storedChars += key.length + json.length;
const STORE_HINT =
"store() is for small state such as IDs or summaries. Show images with image(), keep large data in variables, or write it to a file with a tool.";
function checkKey(name, key) {
if (typeof key !== "string") throw new TypeError(name + "() key must be a string");
}
function store(key, value) {
checkKey("store", key);
const previous = stored.has(key) ? key.length + stored.get(key).length : 0;
if (value === undefined) {
stored.delete(key);
storedChars -= previous;
writes.set(key, undefined);
return;
}
let json;
try {
json = stringify(value);
} catch (error) {
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable: " + format(error));
}
if (json === undefined) {
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable");
}
if (json.length > ${MAX_STORE_VALUE_CHARS}) {
throw new RangeError(
"store(" + stringify(key) + ") value has " + json.length + " characters of JSON, more than the limit of ${MAX_STORE_VALUE_CHARS}. " +
STORE_HINT,
);
}
const next = storedChars - previous + key.length + json.length;
if (next > ${MAX_STORE_TOTAL_CHARS}) {
throw new RangeError(
"store is full: stored values would exceed ${MAX_STORE_TOTAL_CHARS} characters of JSON. Delete keys with store(key, undefined). " +
STORE_HINT,
);
}
stored.set(key, json);
storedChars = next;
writes.set(key, json);
}
function load(key) {
checkKey("load", key);
const json = stored.get(key);
return json === undefined ? undefined : parse(json);
}
function serializeWrites() {
const entries = [];
for (const [key, json] of writes) entries.push(json === undefined ? [key] : [key, json]);
return stringify(entries);
}
Object.defineProperty(globalThis, "store", { value: store, enumerable: true });
Object.defineProperty(globalThis, "load", { value: load, enumerable: true });
let outputChars = 0;
let outputItems = 0;
// Past the output limits the script fails: done() reports the error, so catching it does not
// resume output, and the host ends the script.
function output(kind, data, mimeType) {
if (finished) return;
outputChars += data.length;
outputItems++;
if (outputChars > ${MAX_OUTPUT_CHARS} || outputItems > ${MAX_OUTPUT_ITEMS}) {
const error = new RangeErrorCtor(
"script output exceeded the limit of ${MAX_OUTPUT_CHARS} characters or ${MAX_OUTPUT_ITEMS} text(), image(), and console calls. " +
"Print a summary instead, or write large data to a file with a tool.",
);
done(false, describeError(error));
throw error;
}
bridge("output", kind, data, mimeType);
}
// Primitives become their string form, everything else JSON.
function outputText(value) {
if (value === undefined || value === null || typeof value !== "object" && typeof value !== "function") {
return String(value);
}
const json = stringify(value);
return json === undefined ? String(value) : json;
}
function text(value) {
let rendered;
try {
rendered = outputText(value);
} catch (error) {
throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
}
output("text", rendered);
}
function imageUrl(value) {
if (typeof value === "string") return value;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
}
if (value.image_url !== undefined) {
if (typeof value.image_url !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
return value.image_url;
}
if (typeof value.type !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
if (value.type !== "image") {
throw new TypeErrorCtor('image only accepts image blocks, got "' + value.type + '"');
}
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected image block data");
if (value.data.toLowerCase().startsWith("data:")) return value.data;
return "data:;base64," + value.data;
}
// Base64 of the signatures of the formats providers accept inline (PNG, JPEG except
// JPEG-LS, GIF, "RIFF....WEBP"). Signatures start at byte 0, so their encodings are prefixes.
const IMAGE_SIGNATURES = [
["image/png", /^iVBORw0KGg/],
["image/jpeg", /^[/]9j[/](?!9)/],
["image/gif", /^R0lGOD[dl]h/],
["image/webp", /^UklG.{8}RUJQ/],
];
function image(value) {
const url = imageUrl(value);
if (url === "") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
const colon = url.indexOf(":");
const scheme = colon === -1 ? "" : url.slice(0, colon).toLowerCase();
if (scheme === "http" || scheme === "https") {
throw new TypeErrorCtor("remote image URLs are not supported in tool outputs. Pass a base64 data URI instead");
}
const comma = url.indexOf(",");
const header = comma === -1 ? [] : url.slice(colon + 1, comma).split(";");
if (scheme !== "data" || comma === -1 || header.slice(1).every((part) => part.toLowerCase() !== "base64")) {
throw new TypeErrorCtor("invalid image output. Pass a base64 data URI instead");
}
// Providers reject the whole request on a bad image, and a persisted image block would be
// resent on every later turn. Line breaks from wrapped base64 are dropped. The declared type
// is ignored in favor of the detected one, as providers also reject mismatches.
const data = url.slice(comma + 1).replace(/\\s+/g, "");
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
throw new TypeErrorCtor("invalid image output. The image data is not valid base64 (truncated or corrupted?)");
}
const head = data.slice(0, 16);
const signature = IMAGE_SIGNATURES.find(([, pattern]) => pattern.test(head));
if (!signature) {
throw new TypeErrorCtor("invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image");
}
output("image", data, signature[0]);
}
function exit() {
let writesJson;
try {
writesJson = serializeWrites();
} catch (error) {
done(false, describeError(error));
throw EXIT;
}
done(true, undefined, writesJson);
throw EXIT;
}
const console = {};
for (const level of ["log", "info", "warn", "error", "debug"]) {
console[level] = (...args) => {
output("console", args.map(format).join(" "));
};
}
Object.freeze(console);
Object.defineProperty(globalThis, "tools", { value: toolsProxy, enumerable: true });
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
return {
settle(id, ok, payload, propertiesJson) {
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
if (!ok) {
const error = new ErrorCtor(payload);
if (propertiesJson !== undefined) {
const properties = parse(propertiesJson);
for (const key of Object.keys(properties)) {
if (key === "name" || key === "message" || key === "stack") continue;
Object.defineProperty(error, key, { value: properties[key], writable: true, enumerable: true, configurable: true });
}
}
entry.reject(error);
return;
}
let value;
try {
value = payload === undefined ? undefined : parse(payload);
} catch (error) {
entry.reject(error);
return;
}
entry.resolve(value);
},
run(fn) {
let promise;
try {
promise = fn(toolsProxy, console);
} catch (error) {
done(false, describeError(error));
return;
}
promiseThen.call(
promise,
(value) => {
let json;
try {
json = serialize(value);
} catch (error) {
done(false, describeError(error));
return;
}
done(true, json, serializeWrites());
},
(error) => {
done(false, describeError(error));
},
);
},
stalled() {
if (finished || pending.size > 0) return false;
done(
false,
stringify({
name: "Error",
message:
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
}),
);
return true;
},
};
})`;

View File

@@ -0,0 +1,93 @@
import type { CodemodeDuring, CodemodeOutputItem } from "../types.ts";
import type { CodemodeWasmModule } from "../wasm.ts";
/**
* Messages between the host (main thread) and the worker. Tool arguments,
* results, and values cross as JSON strings: the worker passes them into and
* out of the QuickJS VM as strings and never builds structured values itself.
*/
/**
* Everything a VM needs to run one script, independent of where it runs. Plain data, so it can
* cross a `postMessage` or a Workers RPC call unchanged.
*/
export interface VmStartData {
code: string;
/** `jsName` is the identifier the script uses; `description` is listed in `ALL_TOOLS`. */
tools: { name: string; jsName: string; description: string }[];
globals: { name: string; spread: boolean }[];
memoryLimitBytes: number | undefined;
/** Snapshot for `load()`: key to JSON text. */
store: Record<string, string>;
/**
* Run deterministically (`CodemodeSandboxBase.start()`): UTC, a virtual clock starting at `startedAt` (ms since
* the epoch) that moves only with delivered results, randomness from `seed`, no `WeakRef`, results applied in
* `seq` order, and `started`/`idle` messages.
*/
deterministic?: { startedAt: number; seed: number };
}
export interface WorkerData extends VmStartData {
/** Compiled `quickjs-wasi` module. Structured clone shares the compiled code with the worker. */
wasm: CodemodeWasmModule;
/**
* One Int32 the host sets to non-zero before terminating the worker. The VM's interrupt handler
* polls it, because Bun's `worker.terminate()` cannot stop a thread that is spinning in wasm.
*/
interrupt: SharedArrayBuffer;
}
/** JSON-encoded `{ name?, message, stack? }` of an error thrown by the script. */
export type ScriptErrorJson = string;
export type WorkerToHostMessage =
| { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined }
| { type: "output"; item: CodemodeOutputItem }
/**
* `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. Deterministic mode: `during`
* is the result the VM was processing when the script finished (0: its first run) and the calls issued by then.
*/
| { type: "done"; ok: true; value: string | undefined; writes: string; during?: CodemodeDuring }
| { type: "done"; ok: false; error: ScriptErrorJson; during?: CodemodeDuring }
/** The VM failed outside the script's control, for example a wasm trap. */
| { type: "crash"; message: string }
/** Deterministic mode, before the script runs: what the VM is, so a replay can refuse a different one. */
| { type: "started"; fingerprint: string }
/**
* Deterministic mode, after the script waits again: having processed result `seq` (0: the script's first run), it
* has issued `issued` calls in total.
*/
| { type: "idle"; seq: number; issued: number };
export type HostToWorkerMessage =
/**
* `payload` is the JSON result when `ok`, otherwise the error message. Deterministic mode: `seq` numbers the
* results from 1 in the order the VM applies them, and the VM's clock reads `now` while it processes this one.
*/
{
type: "result";
id: number;
ok: boolean;
payload: string | undefined;
seq?: number;
now?: number;
/** Deterministic mode, `ok: false`: a JSON object whose properties the script's `Error` gets. */
properties?: string;
};
export function isWorkerToHostMessage(value: unknown): value is WorkerToHostMessage {
if (typeof value !== "object" || value === null) return false;
const type = (value as { type?: unknown }).type;
return (
type === "call" ||
type === "output" ||
type === "done" ||
type === "crash" ||
type === "started" ||
type === "idle"
);
}
export function isHostToWorkerMessage(value: unknown): value is HostToWorkerMessage {
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "result";
}

View File

@@ -0,0 +1,277 @@
/**
* Runs the VM in another isolate, reached over an RPC boundary such as a Cloudflare Durable Object
* stub, while tools keep running in the caller.
*
* Problem: a VM on the calling thread blocks that thread while the script computes. In a Durable
* Object that serves an agent session, a CPU-heavy script would stall every other request to the
* session (including the user's abort) for seconds. Measured on Cloudflare (October 2026), a
* script that computed for 10 seconds delayed the session's other requests by 10 seconds when it
* ran in the session object or in a Dynamic Worker, but by about 50 ms when it ran in a Durable
* Object of a separately deployed Worker.
*
* Solution: the caller keeps the host role (`RemoteCodemodeSandbox`: tools, call records,
* deadline, abort) and the remote side only runs the VM (`serveCodemodeRemote`, behind
* `CodemodeSandboxDurableObject` in `@earendil-works/pi-codemode/cloudflare`).
*
* Protocol, one RPC call per execution:
*
* client remote
* remote.execute(request, exchange) ───────▶ starts the VM (in-thread, with interrupt budget)
* ◀─────── exchange([...outputs, call]) one call per tool call
* runs the tool, replies with result ──────▶ VM continues
* ◀─────── exchange([...outputs, done]) terminal, reply ignored
* execute() resolves ◀────────────────────── VM disposed
*
* `exchange` is a function the client passes in; over Workers RPC it becomes a stub that calls back
* into the client. Output is buffered on the remote side and sent with the next call or the
* terminal message, so a script that prints in a loop does not make one RPC per line. Each
* `exchange` carries at most one call, so concurrent tool calls are concurrent RPCs. Calls on one
* stub are delivered in order, which keeps output ordered before the call or result that follows.
*
* Termination: the client cannot interrupt a remote VM that is computing. When the client finishes
* first (deadline, abort, `close()`), it answers every pending and later `exchange` with
* `{ type: "stop" }`, and the remote stops the VM at that point. A script that computes without
* calling a tool runs until its interrupt budget is used up. The remote reports that as a
* `failure` message, which the client turns into the same `kind: "timeout"` error an in-thread
* VM produces.
*
* Failure: if the RPC call rejects (the remote isolate crashed, hit its CPU limit, or was
* redeployed) or returns without a terminal message, the execution fails with `kind: "sandbox"`.
*/
import type { CodemodeError, CodemodeSandboxBaseOptions } from "../types.ts";
import type { CodemodeWasmModule } from "../wasm.ts";
import { CodemodeSandboxBase, errorMessage, type VmChannel, type VmLauncher } from "./execution.ts";
import { DEFAULT_INTERRUPT_BUDGET, inlineLauncher } from "./inline.ts";
import {
type HostToWorkerMessage,
isWorkerToHostMessage,
type VmStartData,
type WorkerToHostMessage,
} from "./protocol.ts";
export interface CodemodeRemoteRequest {
data: VmStartData;
/** Requested interrupt budget. The remote may lower it, see {@link CodemodeServeOptions}. */
interruptBudget?: number;
}
/** What the remote sends to the client: VM messages, or a failure outside the VM bridge. */
export type CodemodeRemoteMessage = WorkerToHostMessage | { type: "failure"; error: CodemodeError };
/**
* The client's answer to an exchange: the result of the call the exchange carried, `null` when it
* carried no call, or `stop` once the client no longer wants the execution.
*/
export type CodemodeRemoteReply = HostToWorkerMessage | { type: "stop" } | null;
export type CodemodeRemoteExchange = (messages: CodemodeRemoteMessage[]) => Promise<CodemodeRemoteReply>;
/**
* The remote end, usually a Durable Object stub for `CodemodeSandboxDurableObject`. Anything with
* this method works, for example an in-process object in tests.
*/
export interface CodemodeRemote {
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void>;
}
export interface RemoteCodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
/**
* Returns the remote that runs one execution, called once per `execute()`. For a Durable Object,
* pick the instance here, for example `() => env.CODEMODE_SANDBOX.getByName(sessionId)`.
*/
remote: () => CodemodeRemote;
/** Interrupt budget requested from the remote. Default: the remote's default. */
interruptBudget?: number;
}
/** Output buffered on the remote before it is flushed without waiting for a call. */
const FLUSH_ITEMS = 1_000;
const FLUSH_CHARS = 1 << 20;
export interface CodemodeServeOptions {
wasm: CodemodeWasmModule | Promise<CodemodeWasmModule>;
/** Upper bound for a request's interrupt budget, and the budget when a request names none. */
maxInterruptBudget?: number;
/** Upper bound for a request's VM memory, and the limit when a request names none. */
maxMemoryLimitBytes?: number;
}
/**
* Remote side of {@link RemoteCodemodeSandbox}: runs one execution's VM on this thread and
* forwards everything it reports through `exchange`. Resolves once the VM has stopped and the
* terminal message has been delivered, or the client asked to stop. Never rejects.
*/
export function serveCodemodeRemote(
request: CodemodeRemoteRequest,
exchange: CodemodeRemoteExchange,
options: CodemodeServeOptions,
): Promise<void> {
const maxBudget = options.maxInterruptBudget ?? DEFAULT_INTERRUPT_BUDGET;
const budget = Math.min(request.interruptBudget ?? maxBudget, maxBudget);
const requestedMemory = request.data.memoryLimitBytes;
const maxMemory = options.maxMemoryLimitBytes;
const memoryLimitBytes =
requestedMemory === undefined
? maxMemory
: maxMemory === undefined
? requestedMemory
: Math.min(requestedMemory, maxMemory);
const data: VmStartData = { ...request.data, memoryLimitBytes };
return new Promise<void>((resolve) => {
let channel: VmChannel | undefined;
let ended = false;
let buffered: CodemodeRemoteMessage[] = [];
let bufferedChars = 0;
/** Replies that arrived before the launcher returned the channel. */
const unsent: HostToWorkerMessage[] = [];
const takeBuffer = (): CodemodeRemoteMessage[] => {
const batch = buffered;
buffered = [];
bufferedChars = 0;
return batch;
};
const end = (terminal: CodemodeRemoteMessage | undefined): void => {
if (ended) return;
ended = true;
const batch = takeBuffer();
const delivered = terminal ? exchange([...batch, terminal]).catch(() => null) : Promise.resolve();
void delivered
.then(() => channel?.stop())
.catch(() => undefined)
.then(() => resolve());
};
const reply = (message: CodemodeRemoteReply): void => {
if (ended || message === null) return;
if (message.type === "stop") {
end(undefined);
return;
}
if (channel) channel.post(message);
else unsent.push(message);
};
const onMessage = (message: unknown): void => {
if (ended) return;
const typed = message as WorkerToHostMessage;
switch (typed.type) {
case "output": {
buffered.push(typed);
bufferedChars += typed.item.type === "text" ? typed.item.text.length : typed.item.data.length;
if (buffered.length >= FLUSH_ITEMS || bufferedChars >= FLUSH_CHARS) {
exchange(takeBuffer()).then(reply, () => end(undefined));
}
break;
}
case "started":
buffered.push(typed);
break;
// Sent at once: a host replaying a script waits for it to see that the script issued no further call.
case "call":
case "idle":
exchange([...takeBuffer(), typed]).then(reply, () => end(undefined));
break;
default:
end(typed);
}
};
const launch = inlineLauncher(options.wasm, budget);
Promise.resolve(
launch(data, {
message: onMessage,
failure: (error) => end({ type: "failure", error }),
}),
).then(
(started) => {
channel = started;
if (ended) {
void started.stop().catch(() => undefined);
return;
}
for (const message of unsent.splice(0)) started.post(message);
},
(error: unknown) => end({ type: "failure", error: { kind: "sandbox", message: errorMessage(error) } }),
);
});
}
function remoteLauncher(remote: () => CodemodeRemote, interruptBudget: number | undefined): VmLauncher {
return (data, events) => {
let stopped = false;
let settled = false;
const waiting = new Map<number, (reply: CodemodeRemoteReply) => void>();
const exchange: CodemodeRemoteExchange = (messages) =>
new Promise<CodemodeRemoteReply>((resolve) => {
if (stopped || !Array.isArray(messages)) {
resolve({ type: "stop" });
return;
}
// Register the waiter first: the host may answer a call synchronously, for example when the
// tool throws before returning a promise.
const call = messages.find((message) => isWorkerToHostMessage(message) && message.type === "call");
if (call?.type === "call") waiting.set(call.id, resolve);
else resolve(null);
for (const message of messages) {
if (typeof message === "object" && message !== null && message.type === "failure") {
settled = true;
events.failure(message.error);
continue;
}
// Malformed messages still go to the host, which fails the execution as a broken bridge.
events.message(message);
if (isWorkerToHostMessage(message) && (message.type === "done" || message.type === "crash"))
settled = true;
}
});
let target: CodemodeRemote;
try {
target = remote();
} catch (error) {
throw new Error(`Failed to reach the remote sandbox: ${errorMessage(error)}`);
}
const request: CodemodeRemoteRequest = interruptBudget === undefined ? { data } : { data, interruptBudget };
Promise.resolve()
.then(() => target.execute(request, exchange))
.then(
() => {
if (!settled && !stopped) {
events.failure({ kind: "sandbox", message: "Remote sandbox returned before the script settled" });
}
},
(error: unknown) => {
if (!stopped)
events.failure({ kind: "sandbox", message: `Remote sandbox failed: ${errorMessage(error)}` });
},
);
return {
post: (message) => {
const resolve = waiting.get(message.id);
if (!resolve) return;
waiting.delete(message.id);
resolve(message);
},
stop: async () => {
stopped = true;
for (const resolve of waiting.values()) resolve({ type: "stop" });
waiting.clear();
},
};
};
}
/**
* Runs each execution's VM on a remote, usually a Cloudflare Durable Object deployed with
* `CodemodeSandboxDurableObject`, while tools run here. Same API and results as
* `CodemodeSandbox`, so it can replace it. Differences: a script that computes without calling a
* tool is limited by the remote's interrupt budget rather than `timeoutMs`, and after a deadline or
* abort the remote VM keeps computing until it next calls a tool or uses up that budget.
*/
export class RemoteCodemodeSandbox extends CodemodeSandboxBase {
constructor(options: RemoteCodemodeSandboxOptions) {
super(options, remoteLauncher(options.remote, options.interruptBudget));
}
}

View File

@@ -0,0 +1,341 @@
/**
* Drives one QuickJS VM through one script, independent of the transport that connects it to the
* host. The worker thread, the in-thread launcher, and the remote server all run the VM through
* this module, so the bridge between prelude and host exists once.
*
* Contract with the caller:
* - `post` receives every message for the host, synchronously from inside VM callbacks. The
* caller must not re-enter the VM from `post` (no `deliver`, no `dispose`); a worker posts to
* another thread, and the in-thread launcher defers delivery to a microtask.
* - `deliver` hands the VM a tool result and runs the script until it waits again. It must only be
* called while the VM is idle, which holds for any caller that never re-enters from `post`.
* - `shouldInterrupt` is polled by QuickJS while the script runs, and here between batches of
* promise jobs. Returning true throws an uncatchable error in the script; the VM then reports
* `crash` or a failed `done`. The job batches matter for a script such as `while (true) await
* null`: every iteration is a separate job that executes only a few opcodes, so QuickJS's own
* polling, which counts opcodes, would let it run for a long time between polls.
* - The VM never decides on its own when it is finished: after `done` or `crash` it stays alive
* until the caller disposes it (or terminates the worker that hosts it).
*
* Failures after the VM exists are reported as a `crash` message; `startVm` only rejects when the
* VM cannot be created at all.
*
* Deterministic mode (`data.deterministic`, used by `CodemodeSandboxBase.start()`): a script must make the same
* calls with the same arguments every time it runs with the same results, so a host can replay it after a restart.
* The VM is otherwise deterministic, because it only runs when the host delivers something and has no timers.
* What remains is fixed here:
* - Time: every WASI clock reads virtual time, `startedAt` and then the `now` of the result being processed, never going backwards.
* While the context is created, the realtime clock reads a value derived from the seed instead, because QuickJS
* seeds `Math.random()` from the first clock read, and which read comes first is the engine's business.
* - Randomness: `random_get` draws from a PRNG over the seed.
* - Timezone: UTC, not the host's.
* - Garbage collection: `WeakRef` and `FinalizationRegistry` are not available; collection timing is not.
* - Delivery order: results carry a `seq` and are applied strictly in that order, buffering early ones, because a
* transport (RPC replies) may deliver them in another order than the host sent them.
* The VM reports `started` with its fingerprint before the script runs, and `idle` with the number of calls issued
* after each drain, so the host can check a replay step by step.
*/
import { Intrinsics, JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
import type { CodemodeWasmModule } from "../wasm.ts";
import { PRELUDE_SOURCE } from "./prelude-source.ts";
import type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./protocol.ts";
export interface VmSession {
deliver(message: HostToWorkerMessage): void;
/** Releases the VM's wasm instance. The session must not be used afterwards. */
dispose(): void;
}
export interface StartVmOptions {
data: VmStartData;
wasm: CodemodeWasmModule;
/**
* QuickJS's guard on its own stack, which turns deep recursion into a catchable `RangeError`.
* The wasm frames also use the host thread's native stack, so the guard must trip before that
* stack runs out: a host overflow fails the whole execution instead. Default: `MAX_STACK_SIZE`
* (512 KiB), which fits the 4 MB stack of a Node worker thread but not a 1 MB main thread.
*/
maxStackSize?: number;
/** Interrupt polls this VM may use, if the launcher counts them; part of the fingerprint. */
interruptBudget?: number;
post(message: WorkerToHostMessage): void;
shouldInterrupt(): boolean;
}
/**
* Bumped whenever this module or the prelude changes what a deterministic script observes, beyond what the
* fingerprint's other parts (engine versions, prelude hash, limits) already capture.
*/
const DETERMINISTIC_VERSION = 1;
/** FNV-1a over UTF-16 code units, as 8 hex digits: identifies the prelude in the fingerprint. */
function fnv1a(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index++) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
const PRELUDE_HASH = fnv1a(PRELUDE_SOURCE);
/** mulberry32: a small, fast PRNG; one 32-bit draw per call. */
function prng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = Math.imul(state ^ (state >>> 15), 1 | state);
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
return (value ^ (value >>> 14)) >>> 0;
};
}
/** The deterministic VM's clock: seed-derived while the context is created, then virtual time. */
interface VirtualClock {
creating: boolean;
startedAt: number;
now: number;
seed: number;
}
/** WASI clock and random functions for deterministic mode, on top of {@link discardOutput}. */
function deterministicWasi(clock: VirtualClock) {
const random = prng(clock.seed ^ 0x9e3779b9);
return (memory: { readonly buffer: ArrayBufferLike }) => ({
...discardOutput(memory),
clock_time_get(clockId: number, _precision: bigint, resultPtr: number): number {
let nanoseconds: bigint;
// QuickJS seeds Math.random() from this reading in microseconds and turns 0 into 1, so seed s reads s + 1.
if (clock.creating) nanoseconds = clockId === 0 ? BigInt(clock.seed + 1) * 1000n : 0n;
else if (clockId === 0) nanoseconds = BigInt(Math.round(clock.now)) * 1_000_000n;
else nanoseconds = BigInt(Math.round(clock.now - clock.startedAt)) * 1_000_000n;
new DataView(memory.buffer).setBigUint64(resultPtr, nanoseconds, true);
return 0;
},
random_get(bufPtr: number, bufLen: number): number {
const bytes = new Uint8Array(memory.buffer, bufPtr, bufLen);
for (let index = 0; index < bufLen; index += 4) {
const value = random();
for (let byte = 0; byte < 4 && index + byte < bufLen; byte++) bytes[index + byte] = value >>> (byte * 8);
}
return 0;
},
});
}
/**
* QuickJS writes engine diagnostics to fd 1 and 2, which the default shim
* forwards to the host's stdout and stderr. That output belongs to the host
* application (for example a TUI), so it is discarded. Reporting every byte as
* written keeps libc from retrying.
*/
function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
return {
fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
const view = new DataView(memory.buffer);
let written = 0;
for (let i = 0; i < iovsLen; i++) {
written += view.getUint32(iovsPtr + i * 8 + 4, true);
}
view.setUint32(nwrittenPtr, written, true);
return 0;
},
};
}
function describeException(error: JSException): string {
const head = error.message ? `${error.name}: ${error.message}` : error.name;
const stack = error.stack?.trimEnd();
return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
}
/** Promise jobs run between two `shouldInterrupt` polls while draining the job queue. */
const JOBS_PER_POLL = 50;
function crashMessage(error: unknown): WorkerToHostMessage {
return { type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) };
}
export async function startVm(options: StartVmOptions): Promise<VmSession> {
const { data, post } = options;
const maxStackSize = options.maxStackSize ?? MAX_STACK_SIZE;
const deterministic = data.deterministic;
const clock: VirtualClock | undefined = deterministic && {
creating: true,
startedAt: deterministic.startedAt,
now: deterministic.startedAt,
seed: deterministic.seed >>> 0,
};
const vm = await QuickJS.create({
wasm: options.wasm,
memoryLimit: data.memoryLimitBytes,
// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
// catchable RangeError.
maxStackSize,
interruptHandler: options.shouldInterrupt,
wasi: clock ? deterministicWasi(clock) : discardOutput,
...(clock ? { timezoneOffset: 0, intrinsics: Intrinsics.ALL & ~Intrinsics.WEAK_REF } : {}),
});
if (clock) clock.creating = false;
let disposed = false;
/** Calls the script has issued, tools and globals; reported with every `idle`. */
let issued = 0;
/** Deterministic mode: the next result to apply, and results that arrived before it. */
let nextSeq = 1;
/** The result being processed, 0 during the script's first run; reported with `done`. */
let current = 0;
const early = new Map<number, HostToWorkerMessage>();
const apply = (message: HostToWorkerMessage): void => {
vm.withScope(() => {
vm.callFunction(
settle,
api,
vm.newNumber(message.id),
message.ok ? vm.true : vm.false,
message.payload === undefined ? vm.undefined : vm.newString(message.payload),
message.properties === undefined ? vm.undefined : vm.newString(message.properties),
);
});
drain();
};
const session: VmSession = {
deliver(message) {
if (disposed) return;
try {
if (!clock) {
apply(message);
return;
}
const { seq } = message;
if (seq === undefined || !Number.isInteger(seq) || seq < nextSeq || early.has(seq)) {
throw new Error(`Result ${message.id} has sequence number ${seq}, expected ${nextSeq} or later, once`);
}
early.set(seq, message);
for (let next = early.get(nextSeq); next !== undefined; next = early.get(nextSeq)) {
early.delete(nextSeq);
// Never backwards: a resumed host's clock may be behind the one that started the run.
if (next.now !== undefined && Number.isFinite(next.now)) clock.now = Math.max(clock.now, next.now);
current = nextSeq;
apply(next);
post({ type: "idle", seq: nextSeq, issued });
nextSeq++;
}
} catch (error) {
post(crashMessage(error));
}
},
dispose() {
if (disposed) return;
disposed = true;
vm.dispose();
},
};
let api: JSValueHandle;
let settle: JSValueHandle;
let stalled: JSValueHandle;
// `executePendingJobs()` runs the queue to empty without a chance to interrupt, so jobs run one at a
// time through the (internal, but public) exports; quickjs-wasi is pinned to an exact version.
const exports = vm._getExports();
/** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
const drain = () => {
let jobs = 0;
while (exports.qjs_is_job_pending()) {
// A job only fails for uncatchable errors, such as an interrupt.
if (exports.qjs_execute_pending_job() < 0) throw new Error("Promise job failed");
if (++jobs % JOBS_PER_POLL === 0 && options.shouldInterrupt()) throw new Error("Execution interrupted");
}
vm.callFunction(stalled, api).dispose();
};
try {
// Called from the prelude with primitives only.
const bridge = vm.newFunction("bridge", (kind, a, b, c) => {
switch (kind.toString()) {
case "call":
case "global":
issued++;
post({
type: "call",
id: a.toNumber(),
target: kind.toString() === "call" ? "tool" : "global",
name: b.toString(),
args: c === undefined || c.isUndefined ? undefined : c.toString(),
});
break;
case "output":
post({
type: "output",
item:
a.toString() === "image"
? { type: "image", data: b.toString(), mimeType: c.toString() }
: a.toString() === "console"
? { type: "text", text: b.toString(), console: true }
: { type: "text", text: b.toString() },
});
break;
case "done": {
// The host stops the VM on `done`, so the `idle` of this drain never arrives: report its numbers here.
const during = clock ? { during: { seq: current, issued } } : {};
if (a.toBoolean()) {
post({
type: "done",
ok: true,
value: b === undefined || b.isUndefined ? undefined : b.toString(),
writes: c.toString(),
...during,
});
} else {
post({ type: "done", ok: false, error: b.toString(), ...during });
}
break;
}
}
return vm.undefined;
});
// These handles live as long as the VM, which the caller disposes as a whole.
api = vm.withScope((scope) =>
scope.escape(
vm.callFunction(
vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
vm.undefined,
bridge,
vm.newString(JSON.stringify(data.tools)),
vm.newString(JSON.stringify(data.globals)),
vm.newString(JSON.stringify(data.store)),
),
),
);
settle = api.getProp("settle");
const run = api.getProp("run");
stalled = api.getProp("stalled");
if (clock) {
const versions = vm.versions;
const budget = options.interruptBudget ?? "none";
post({
type: "started",
fingerprint: `quickjs-wasi ${versions["quickjs-wasi"]}, quickjs ${versions.quickjs}, prelude ${PRELUDE_HASH}, deterministic ${DETERMINISTIC_VERSION}, stack ${maxStackSize}, heap ${data.memoryLimitBytes ?? "none"}, budget ${budget}`,
});
}
// The prefix shares the first line with the script so reported line numbers
// match the script as written.
let fn: JSValueHandle;
try {
fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");
} catch (error) {
if (!(error instanceof JSException)) throw error;
post({ type: "done", ok: false, error: describeException(error) });
return session;
}
vm.callFunction(run, api, fn).dispose();
fn.dispose();
drain();
if (clock) post({ type: "idle", seq: 0, issued });
} catch (error) {
post(crashMessage(error));
}
return session;
}

View File

@@ -0,0 +1,39 @@
/**
* Worker thread entry. One worker runs one script inside a fresh QuickJS VM
* (a separate wasm instance), relays tool calls and output to the host, and
* reports the result. The host terminates the worker when the script settles,
* times out, or is aborted; the worker exists so that a spinning script never
* blocks the host thread.
*
* Importing this module starts the worker. Hosts that bundle their code (for
* example a Bun compiled executable) add a file that imports
* `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
* or embedded-module string specifier as `workerUrl`.
*/
import { parentPort, workerData } from "node:worker_threads";
import { isHostToWorkerMessage, type WorkerData, type WorkerToHostMessage } from "./protocol.ts";
import { startVm } from "./vm.ts";
function post(message: WorkerToHostMessage): void {
parentPort?.postMessage(message);
}
async function main(data: WorkerData): Promise<void> {
const interrupt = new Int32Array(data.interrupt);
// The VM lives until the host terminates the worker, so the session is never disposed.
const session = await startVm({
data,
wasm: data.wasm,
post,
shouldInterrupt: () => Atomics.load(interrupt, 0) !== 0,
});
parentPort?.on("message", (message: unknown) => {
if (isHostToWorkerMessage(message)) session.deliver(message);
});
}
if (parentPort) {
main(workerData as WorkerData).catch((error: unknown) => {
post({ type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
});
}