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,19 @@
// Modules that only exist when cloudflare.ts is built by Wrangler. Consumers get the real
// declarations from @cloudflare/workers-types or `wrangler types`; these keep this repository's
// type check free of a Workers type dependency and are not published.
declare module "cloudflare:workers" {
export abstract class DurableObject<Env = unknown> {
protected ctx: unknown;
protected env: Env;
constructor(ctx: unknown, env: Env);
}
}
// A pattern, not the exact specifier: coding-agent declares "quickjs-wasi/quickjs.wasm" as a path
// for Bun, and an exact declaration takes precedence where both are visible. cloudflare.ts casts the
// import to the compiled module Wrangler actually produces.
declare module "*/quickjs.wasm" {
const module: unknown;
export default module;
}

View File

@@ -0,0 +1,75 @@
/**
* Cloudflare Workers entry, for Workers built with Wrangler.
*
* Wrangler's default `CompiledWasm` rule turns the `.wasm` import below into a
* `WebAssembly.Module`; Workers cannot compile WebAssembly from bytes at runtime. Other bundlers
* do not support the import: use `@earendil-works/pi-codemode/portable` with your own module there.
*
* Two ways to run codemode on Cloudflare:
* - `RemoteCodemodeSandbox` with a {@link CodemodeSandboxDurableObject} deployed as its own
* Worker: scripts run in a separate isolate and never block the caller. Use this from a Durable
* Object that serves a session.
* - {@link createInlineCodemodeSandbox}: scripts run on the caller's thread. Simplest; a script
* that computes blocks the caller until it awaits a tool or uses up its interrupt budget.
*/
import { DurableObject } from "cloudflare:workers";
import quickjsWasmImport from "quickjs-wasi/quickjs.wasm";
import {
DEFAULT_INTERRUPT_BUDGET,
InlineCodemodeSandbox,
type InlineCodemodeSandboxOptions,
} from "./runtime/inline.ts";
import { type CodemodeRemoteExchange, type CodemodeRemoteRequest, serveCodemodeRemote } from "./runtime/remote.ts";
import type { CodemodeWasmModule } from "./wasm.ts";
export * from "./portable.ts";
// Wrangler's `CompiledWasm` rule makes this a compiled module; the declaration cannot say so (see
// cloudflare-modules.d.ts).
const quickjsWasm = quickjsWasmImport as unknown as CodemodeWasmModule;
/** Default VM memory limit of {@link CodemodeSandboxDurableObject}. Isolates have 128 MB in total. */
export const DEFAULT_REMOTE_MEMORY_LIMIT_BYTES = 32 * 1024 * 1024;
/** `quickjs-wasi/quickjs.wasm`, compiled by Wrangler. */
export const cloudflareQuickJSWasm: CodemodeWasmModule = quickjsWasm;
/** `InlineCodemodeSandbox` with the QuickJS module Wrangler bundled. */
export function createInlineCodemodeSandbox(options: Omit<InlineCodemodeSandboxOptions, "wasm"> = {}) {
return new InlineCodemodeSandbox({ ...options, wasm: quickjsWasm });
}
/**
* The remote end of `RemoteCodemodeSandbox`: a Durable Object whose one RPC method runs a script's
* VM and calls back into the client for every tool call.
*
* Deploy it as its own Worker and bind it from the Worker that runs your agent with `script_name`.
* A separate Worker is what keeps scripts off the agent's thread: a class in the same Worker can
* share an isolate, and therefore a thread, with the objects that call it.
*
* The object keeps no state between executions and never touches storage. Executions that reach
* the same instance share its thread: they interleave while they wait on tools, and a script that
* computes delays the others. Name instances per session (`getByName(sessionId)`) to keep one
* session's scripts together, or per execution for full independence.
*
* Limits apply to every request, whatever the client asks for. Override the fields in a subclass:
*
* export class CodemodeSandbox extends CodemodeSandboxDurableObject {
* protected maxInterruptBudget = 200_000;
* }
*/
export class CodemodeSandboxDurableObject<Env = unknown> extends DurableObject<Env> {
/** Interrupt polls per execution; the client may only ask for less. See `InlineCodemodeSandboxOptions`. */
protected maxInterruptBudget: number = DEFAULT_INTERRUPT_BUDGET;
/** VM memory per execution; the client may only ask for less. */
protected maxMemoryLimitBytes: number = DEFAULT_REMOTE_MEMORY_LIMIT_BYTES;
/** Called by `RemoteCodemodeSandbox` over RPC. Resolves when the script has settled or was stopped. */
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange): Promise<void> {
return serveCodemodeRemote(request, exchange, {
wasm: quickjsWasm,
maxInterruptBudget: this.maxInterruptBudget,
maxMemoryLimitBytes: this.maxMemoryLimitBytes,
});
}
}

View File

@@ -0,0 +1,340 @@
import { toCodemodeIdentifier } from "./identifier.ts";
export { toCodemodeIdentifier };
import type { CodemodeJsonSchema, CodemodeTool } from "./types.ts";
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const INDENT = " ";
/** Largest rendered input type, in characters, before it becomes `unknown`. */
export const DEFAULT_INPUT_SCHEMA_MAX_CHARS = 16_000;
/** Local `$ref` expansions per rendered schema, so shared definitions cannot blow up the output. */
const MAX_REF_EXPANSIONS = 32;
/** JSON Schema keywords with a number or string value shown as `@keyword value` property tags. */
const TAG_KEYWORDS = [
"minimum",
"exclusiveMinimum",
"maximum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"pattern",
"format",
"minItems",
"maxItems",
];
/** Tag keywords whose values all hold at once, so only the largest (lower) or smallest (upper) matters. */
const LOWER_BOUNDS = new Set(["minimum", "exclusiveMinimum", "minLength", "minItems"]);
const UPPER_BOUNDS = new Set(["maximum", "exclusiveMaximum", "maxLength", "maxItems"]);
/** Longest property tag; longer ones (large defaults, long patterns) are left out. */
const MAX_TAG_CHARS = 80;
/** Characters that end a `//` comment. */
const LINE_TERMINATORS = /[\r\n\u2028\u2029]/g;
export interface RenderDeclarationsOptions {
tools?: readonly CodemodeTool[];
globals?: readonly CodemodeTool[];
}
/**
* Render TypeScript declarations for the script-visible API. Tools become members of
* `declare const tools`, globals become `declare function` statements, and `ns.member` globals
* members of `declare const ns`. Descriptions become doc comments; schemas become types.
*/
export function renderDeclarations(options: RenderDeclarationsOptions): string {
const sections: string[] = [];
const tools = options.tools ?? [];
if (tools.length > 0) {
const members = tools.map(
(tool) => `${docComment(tool.description, INDENT)}${INDENT}${renderToolSignature(tool)}`,
);
sections.push(`declare const tools: {\n${members.join("\n")}\n};`);
}
const namespaces = new Map<string, string[]>();
for (const global of options.globals ?? []) {
const dot = global.name.indexOf(".");
if (dot === -1) {
sections.push(renderGlobal(`declare function ${global.name}`, global, ""));
continue;
}
const namespace = global.name.slice(0, dot);
const members = namespaces.get(namespace) ?? [];
if (members.length === 0) namespaces.set(namespace, members);
members.push(renderGlobal(global.name.slice(dot + 1), global, INDENT));
}
for (const [namespace, members] of namespaces) {
sections.push(`declare const ${namespace}: {\n${members.join("\n")}\n};`);
}
return sections.join("\n\n");
}
/**
* One tool as a member of the `tools` object: `name(args: T): Promise<R>;` with the
* name as the identifier scripts use. Input types longer than `inputMaxChars` render as `unknown`.
*/
export function renderToolSignature(
tool: Pick<CodemodeTool, "name" | "inputSchema" | "outputSchema">,
options: { inputMaxChars?: number } = {},
): string {
const input =
tool.inputSchema === undefined
? "unknown"
: schemaToType(tool.inputSchema, { maxChars: options.inputMaxChars ?? DEFAULT_INPUT_SCHEMA_MAX_CHARS });
return `${toCodemodeIdentifier(tool.name)}(args: ${input}): Promise<${renderToolOutputType(tool.outputSchema)}>;`;
}
/**
* A tool's sample: the description followed by the tool's declaration. Used for tool
* listings and `ALL_TOOLS` entries.
*/
export function renderToolSample(
tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">,
options: { inputMaxChars?: number } = {},
): string {
const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`;
return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;
}
/** The type a tool call resolves to: the output schema's type, and `unknown` without a schema. */
export function renderToolOutputType(schema: CodemodeJsonSchema | undefined): string {
return schema === undefined ? "unknown" : schemaToType(schema);
}
function renderGlobal(head: string, global: CodemodeTool, indent: string): string {
if (global.signature !== undefined) {
return `${docComment(global.description, indent)}${indent}${head}${global.signature};`;
}
const input = global.inputSchema === undefined ? "unknown" : schemaToType(global.inputSchema);
const output = global.outputSchema === undefined ? "unknown" : schemaToType(global.outputSchema);
return `${docComment(global.description, indent)}${indent}${head}(args: ${input}): Promise<${output}>;`;
}
function docComment(description: string | undefined, indent: string): string {
const text = description?.trim();
if (!text) return "";
const lines = text.replaceAll("*/", "*\\/").split(/\r?\n/);
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`;
return `${indent}/**\n${lines.map((line) => `${indent} *${line ? ` ${line}` : ""}`).join("\n")}\n${indent} */\n`;
}
function propertyKey(name: string): string {
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
}
function union(types: string[]): string {
const unique = [...new Set(types)];
if (unique.includes("unknown")) return "unknown";
return unique.length === 0 ? "never" : unique.join(" | ");
}
/**
* Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name,
* or one property per line with `//` comments when a property has a description or constraints the
* type cannot express (`// @integer @minimum 1 @default 20`); `Array<T>` for arrays. Local references
* (`#/$defs/...`, `#/definitions/...`) resolve against `schema`; recursive and remote references
* render as `unknown`. A result longer than `maxChars` is rendered again without the constraint tags,
* and renders as `unknown` if it is still too long.
*/
export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string {
const render = (tags: boolean) => toType(schema, { root: schema, resolving: new Set(), expansions: 0, tags });
const type = render(true);
if (options.maxChars === undefined || type.length <= options.maxChars) return type;
const untagged = render(false);
return untagged.length <= options.maxChars ? untagged : "unknown";
}
interface SchemaContext {
root: CodemodeJsonSchema;
/** References being expanded on the current path, to stop at recursive types. */
resolving: Set<string>;
expansions: number;
/** Whether property comments include constraint tags. */
tags: boolean;
}
function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined {
if (ref !== "#" && !ref.startsWith("#/")) return undefined;
let current: unknown = root;
for (const segment of ref.slice(2).split("/").filter(Boolean)) {
const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~");
if (typeof current !== "object" || current === null || Array.isArray(current) || !(key in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
if (typeof current === "boolean") return current;
return typeof current === "object" && current !== null && !Array.isArray(current)
? (current as Record<string, unknown>)
: undefined;
}
function toType(schema: CodemodeJsonSchema, context: SchemaContext): string {
if (schema === true) return "unknown";
if (schema === false) return "never";
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "unknown";
if (typeof schema.$ref === "string") {
const ref = schema.$ref;
if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown";
const target = resolveRef(ref, context.root);
if (target === undefined) return "unknown";
context.expansions++;
context.resolving.add(ref);
try {
return toType(target, context);
} finally {
context.resolving.delete(ref);
}
}
if ("const" in schema) return JSON.stringify(schema.const) ?? "unknown";
if (Array.isArray(schema.enum)) return union(schema.enum.map((value) => JSON.stringify(value) ?? "unknown"));
const variants = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
if (variants) return union(variants.map((variant) => toType(variant as CodemodeJsonSchema, context)));
if (Array.isArray(schema.allOf)) {
const parts = schema.allOf
.map((part) => toType(part as CodemodeJsonSchema, context))
.filter((part) => part !== "unknown");
return parts.length === 0
? "unknown"
: parts.map((part) => (part.includes(" | ") ? `(${part})` : part)).join(" & ");
}
const type = schema.type;
if (Array.isArray(type)) return union(type.map((entry) => toType({ ...schema, type: entry }, context)));
switch (type) {
case "string":
return "string";
case "number":
case "integer":
return "number";
case "boolean":
return "boolean";
case "null":
return "null";
case "array":
return arrayType(schema, context);
case "object":
return objectType(schema, context);
case undefined:
if ("properties" in schema || "additionalProperties" in schema || "required" in schema) {
return objectType(schema, context);
}
if ("items" in schema || "prefixItems" in schema) return arrayType(schema, context);
return "unknown";
default:
return "unknown";
}
}
function arrayType(schema: Record<string, unknown>, context: SchemaContext): string {
if (schema.items !== undefined && !Array.isArray(schema.items)) {
return `Array<${toType(schema.items as CodemodeJsonSchema, context)}>`;
}
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: [];
if (tuple.length > 0) return `[${tuple.map((item) => toType(item as CodemodeJsonSchema, context)).join(", ")}]`;
return "unknown[]";
}
function descriptionOf(property: unknown): string {
const description = (property as { description?: unknown } | null | undefined)?.description;
return typeof description === "string" ? description.trim() : "";
}
/**
* Constraints of a property that its TypeScript type cannot express, as JSDoc-style tags:
* `@integer @minimum 1 @maximum 100 @default 20`. Empty when there are none.
*/
function constraintTags(schema: unknown): string {
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return "";
const property = schema as Record<string, unknown>;
// Optional fields such as Pydantic's `int | None` keep their constraints in the non-null branch.
// The branch's constraints apply together with the property's, so both are read.
const variants = Array.isArray(property.anyOf) ? property.anyOf : property.oneOf;
const nonNull: unknown[] = Array.isArray(variants)
? variants.filter((variant) => (variant as { type?: unknown } | null)?.type !== "null")
: [];
const branch = nonNull.length === 1 ? nonNull[0] : undefined;
const branches =
typeof branch === "object" && branch !== null && !Array.isArray(branch)
? [property, branch as Record<string, unknown>]
: [property];
// Draft-04 and OpenAPI 3.0 make `minimum`/`maximum` exclusive with a boolean `exclusiveMinimum`/`exclusiveMaximum`.
const schemas = branches.map(
(schema): Record<string, unknown> => ({
...schema,
...(schema.exclusiveMinimum === true ? { minimum: undefined, exclusiveMinimum: schema.minimum } : {}),
...(schema.exclusiveMaximum === true ? { maximum: undefined, exclusiveMaximum: schema.maximum } : {}),
}),
);
const tags: string[] = [];
const isInteger = (type: unknown) => type === "integer" || (Array.isArray(type) && type.includes("integer"));
if (schemas.some((schema) => isInteger(schema.type))) tags.push("@integer");
for (const keyword of TAG_KEYWORDS) {
const values = schemas.map((schema) => schema[keyword]);
if (LOWER_BOUNDS.has(keyword) || UPPER_BOUNDS.has(keyword)) {
const numbers = values.filter((value) => typeof value === "number");
if (numbers.length === 0) continue;
tags.push(`@${keyword} ${LOWER_BOUNDS.has(keyword) ? Math.max(...numbers) : Math.min(...numbers)}`);
continue;
}
for (const value of new Set(values)) {
if (typeof value === "number" || typeof value === "string") tags.push(`@${keyword} ${value}`);
}
}
if (schemas.some((schema) => schema.uniqueItems === true)) tags.push("@uniqueItems");
// Annotations, unlike constraints, come from the property itself.
if (property.default !== undefined && property.default !== null) {
tags.push(`@default ${JSON.stringify(property.default)}`);
}
if (property.deprecated === true) tags.push("@deprecated");
return tags
.filter((tag) => tag.length <= MAX_TAG_CHARS)
.map((tag) => tag.replace(LINE_TERMINATORS, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`))
.join(" ");
}
function objectType(schema: Record<string, unknown>, context: SchemaContext): string {
const properties =
typeof schema.properties === "object" && schema.properties !== null && !Array.isArray(schema.properties)
? (schema.properties as Record<string, unknown>)
: {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const names = Object.keys(properties).sort();
const members = names.map((name) => {
const optional = required.has(name) ? "" : "?";
return `${propertyKey(name)}${optional}: ${toType(properties[name] as CodemodeJsonSchema, context)};`;
});
const additional = schema.additionalProperties;
if (additional !== undefined && additional !== false) {
const type = additional === true ? "unknown" : toType(additional as CodemodeJsonSchema, context);
members.push(`[key: string]: ${type};`);
} else if (additional === undefined && names.length === 0) {
members.push("[key: string]: unknown;");
}
if (members.length === 0) return "{}";
const comments = names.map((name) => {
const lines = descriptionOf(properties[name])
.split(LINE_TERMINATORS)
.map((line) => line.trim())
.filter(Boolean);
const tags = context.tags ? constraintTags(properties[name]) : "";
return tags ? [...lines, tags] : lines;
});
if (!comments.some((comment) => comment.length > 0)) return `{ ${members.join(" ")} }`;
const lines = ["{"];
comments.forEach((comment, index) => {
for (const line of comment) lines.push(`${INDENT}// ${line}`);
lines.push(`${INDENT}${members[index].replaceAll("\n", `\n${INDENT}`)}`);
});
for (const member of members.slice(names.length)) lines.push(`${INDENT}${member}`);
lines.push("}");
return lines.join("\n");
}

View File

@@ -0,0 +1,12 @@
/**
* The identifier a script uses for a tool: characters that are not valid in a JavaScript
* identifier become `_`. `mcp__docs__search` stays as is, `my-tool` becomes `my_tool`.
*/
export function toCodemodeIdentifier(name: string): string {
let identifier = "";
for (const char of name) {
const valid = identifier === "" ? /^[A-Za-z_$]$/.test(char) : /^[A-Za-z0-9_$]$/.test(char);
identifier += valid ? char : "_";
}
return identifier === "" ? "_" : identifier;
}

View File

@@ -0,0 +1,65 @@
export {
DEFAULT_INPUT_SCHEMA_MAX_CHARS,
type RenderDeclarationsOptions,
renderDeclarations,
renderToolOutputType,
renderToolSample,
renderToolSignature,
schemaToType,
} from "./declarations.ts";
export { toCodemodeIdentifier } from "./identifier.ts";
export { CodemodeSandboxBase } from "./runtime/execution.ts";
export { CodemodeSandbox } from "./runtime/host.ts";
export {
DEFAULT_INTERRUPT_BUDGET,
InlineCodemodeSandbox,
type InlineCodemodeSandboxOptions,
} from "./runtime/inline.ts";
export {
MAX_OUTPUT_CHARS,
MAX_OUTPUT_ITEMS,
MAX_STORE_TOTAL_CHARS,
MAX_STORE_VALUE_CHARS,
} from "./runtime/prelude-source.ts";
export {
type CodemodeRemote,
type CodemodeRemoteExchange,
type CodemodeRemoteMessage,
type CodemodeRemoteReply,
type CodemodeRemoteRequest,
type CodemodeServeOptions,
RemoteCodemodeSandbox,
type RemoteCodemodeSandboxOptions,
serveCodemodeRemote,
} from "./runtime/remote.ts";
export {
CODEMODE_OPTIONS_PREFIX,
CODEMODE_SOURCE_GRAMMAR,
CodemodeSourceError,
type CodemodeSourceOptions,
type ParsedCodemodeSource,
parseCodemodeSource,
} from "./source.ts";
export type {
CodemodeCall,
CodemodeCallStatus,
CodemodeDeclaredGlobal,
CodemodeDeclaredTool,
CodemodeDuring,
CodemodeError,
CodemodeErrorKind,
CodemodeExecuteOptions,
CodemodeHostCall,
CodemodeJsonSchema,
CodemodeOutcome,
CodemodeOutputItem,
CodemodeResult,
CodemodeRun,
CodemodeRunOptions,
CodemodeSandboxBaseOptions,
CodemodeSandboxOptions,
CodemodeStoreWrites,
CodemodeTool,
CodemodeToolContext,
} from "./types.ts";
export { type CodemodeWasmModule, loadQuickJSWasm } from "./wasm.ts";

View File

@@ -0,0 +1,53 @@
/**
* Sandboxes that need no Node built-ins: `InlineCodemodeSandbox` runs the VM on the calling thread
* and `RemoteCodemodeSandbox` runs it in another isolate. Neither imports `node:*` modules, so this
* entry works in Cloudflare Workers, Durable Objects, and browsers. `CodemodeSandbox`, which uses
* worker threads, stays in the main entry.
*/
export { toCodemodeIdentifier } from "./identifier.ts";
export { CodemodeSandboxBase } from "./runtime/execution.ts";
export {
DEFAULT_INTERRUPT_BUDGET,
InlineCodemodeSandbox,
type InlineCodemodeSandboxOptions,
} from "./runtime/inline.ts";
export {
MAX_OUTPUT_CHARS,
MAX_OUTPUT_ITEMS,
MAX_STORE_TOTAL_CHARS,
MAX_STORE_VALUE_CHARS,
} from "./runtime/prelude-source.ts";
export type { HostToWorkerMessage, VmStartData, WorkerToHostMessage } from "./runtime/protocol.ts";
export {
type CodemodeRemote,
type CodemodeRemoteExchange,
type CodemodeRemoteMessage,
type CodemodeRemoteReply,
type CodemodeRemoteRequest,
type CodemodeServeOptions,
RemoteCodemodeSandbox,
type RemoteCodemodeSandboxOptions,
serveCodemodeRemote,
} from "./runtime/remote.ts";
export type {
CodemodeCall,
CodemodeCallStatus,
CodemodeDeclaredGlobal,
CodemodeDeclaredTool,
CodemodeDuring,
CodemodeError,
CodemodeErrorKind,
CodemodeExecuteOptions,
CodemodeHostCall,
CodemodeJsonSchema,
CodemodeOutcome,
CodemodeOutputItem,
CodemodeResult,
CodemodeRun,
CodemodeRunOptions,
CodemodeSandboxBaseOptions,
CodemodeStoreWrites,
CodemodeTool,
CodemodeToolContext,
} from "./types.ts";
export type { CodemodeWasmModule } from "./wasm.ts";

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) });
});
}

View File

@@ -0,0 +1,125 @@
/**
* Codemode source format: JavaScript, optionally preceded by one options line.
*
* ```js
* // @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
* const text = await tools.read({ path: "package.json" });
* text(JSON.parse(text).name);
* ```
*/
export const CODEMODE_OPTIONS_PREFIX = "// @options:";
const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
const SUPPORTED_FIELDS_TEXT = "`max_output_tokens` and `timeout_ms`";
/** Largest delay `setTimeout` supports, which bounds `timeout_ms`. */
const MAX_TIMEOUT_MS = 2_147_483_647;
/**
* Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the
* options line; the options JSON and the code are checked by {@link parseCodemodeSource}.
*/
export const CODEMODE_SOURCE_GRAMMAR = String.raw`
start: options_source | plain_source
options_source: OPTIONS_LINE NEWLINE SOURCE
plain_source: SOURCE
OPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
`;
export interface CodemodeSourceOptions {
/** Token budget for the script's output. */
maxOutputTokens?: number;
/** Hard deadline for the whole script in milliseconds, including tool calls. */
timeoutMs?: number;
}
export interface ParsedCodemodeSource {
/** The script with the options line replaced by an empty line, so line numbers are unchanged. */
code: string;
options: CodemodeSourceOptions;
}
export class CodemodeSourceError extends Error {
constructor(message: string) {
super(message);
this.name = "CodemodeSourceError";
}
}
function isSafeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function parseOptions(directive: string): CodemodeSourceOptions {
if (directive === "") {
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
}
let value: unknown;
try {
value = JSON.parse(directive);
} catch (error) {
throw new CodemodeSourceError(
`@options must be valid JSON with supported fields ${SUPPORTED_FIELDS_TEXT}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
}
const fields = value as Record<string, unknown>;
for (const key of Object.keys(fields)) {
if (!(SUPPORTED_FIELDS as readonly string[]).includes(key)) {
throw new CodemodeSourceError(`@options only supports ${SUPPORTED_FIELDS_TEXT}; got \`${key}\``);
}
}
const options: CodemodeSourceOptions = {};
const { max_output_tokens, timeout_ms } = fields;
if (max_output_tokens !== undefined) {
if (!isSafeInteger(max_output_tokens)) {
throw new CodemodeSourceError("@options field `max_output_tokens` must be a non-negative safe integer");
}
options.maxOutputTokens = max_output_tokens;
}
if (timeout_ms !== undefined) {
if (!isSafeInteger(timeout_ms) || timeout_ms === 0 || timeout_ms > MAX_TIMEOUT_MS) {
throw new CodemodeSourceError(
`@options field \`timeout_ms\` must be a positive integer up to ${MAX_TIMEOUT_MS}`,
);
}
options.timeoutMs = timeout_ms;
}
return options;
}
/**
* Split an optional first-line `// @options: {...}` from the script. Throws
* {@link CodemodeSourceError} for empty input, invalid options, and an options line that is not the
* first line, which would otherwise be ignored without notice.
*/
export function parseCodemodeSource(input: string): ParsedCodemodeSource {
if (input.trim() === "") {
throw new CodemodeSourceError(
'Expected JavaScript source text (non-empty). Provide JS only, optionally with a first line `// @options: {"max_output_tokens": 1000}`.',
);
}
const lines = input.split("\n");
const misplaced = lines.findIndex(
(line, index) => index > 0 && line.trimStart().startsWith(CODEMODE_OPTIONS_PREFIX),
);
if (misplaced !== -1) {
throw new CodemodeSourceError(
`The ${CODEMODE_OPTIONS_PREFIX} line must be the first line of the input; found one on line ${misplaced + 1}`,
);
}
const newline = input.indexOf("\n");
const firstLine = lines[0].replace(/\r$/, "");
const trimmed = firstLine.trimStart();
if (!trimmed.startsWith(CODEMODE_OPTIONS_PREFIX)) return { code: input, options: {} };
const code = newline === -1 ? "" : input.slice(newline);
if (code.trim() === "") {
throw new CodemodeSourceError("The @options line must be followed by JavaScript source on subsequent lines");
}
return { code, options: parseOptions(trimmed.slice(CODEMODE_OPTIONS_PREFIX.length).trim()) };
}

View File

@@ -0,0 +1,254 @@
import type { CodemodeWasmModule } from "./wasm.ts";
export interface CodemodeToolContext {
/**
* Aborted when the script finishes (including unawaited calls), the
* execution times out, the caller aborts, or the sandbox is closed.
*/
signal: AbortSignal;
}
/** A JSON Schema document. Only used to render declarations; values are not validated against it. */
export type CodemodeJsonSchema = { [key: string]: unknown } | boolean;
export interface CodemodeTool {
/**
* The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that
* are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as
* `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or
* `<namespace>.<member>`, which groups them into a frozen namespace object.
*/
name: string;
/** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */
description?: string;
/** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */
inputSchema?: CodemodeJsonSchema;
/** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */
outputSchema?: CodemodeJsonSchema;
/** Globals only: `execute` receives all call arguments as an array instead of the first one. */
spread?: boolean;
/**
* Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for
* example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas.
*/
signature?: string;
/**
* `args` is whatever the script passed, after a JSON round trip. The return
* value must be JSON-serializable; a thrown error surfaces in the script as
* an `Error` with the same message.
*/
execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;
}
/**
* One item of the script's output, in the order the script produced it: `text()` and `console.*`
* produce text items, with `console: true` for `console.*`, and `image()` image items. `data` is base64.
*/
export type CodemodeOutputItem =
| { type: "text"; text: string; console?: true }
| { type: "image"; data: string; mimeType: string };
export type CodemodeCallStatus = "ok" | "error" | "cancelled";
export interface CodemodeCall {
name: string;
status: CodemodeCallStatus;
durationMs: number;
}
export type CodemodeErrorKind =
/** The script threw or failed to parse. `name` and `stack` come from the script's error. */
| "script"
/**
* The overall deadline expired, or a VM on the calling thread used up its interrupt budget.
* The execution ended and the VM was asked to stop; a worker or remote VM may still be computing.
*/
| "timeout"
/** The caller's signal fired or the sandbox was closed. The execution ended and the VM was asked to stop. */
| "aborted"
/**
* The VM or its transport failed outside the script's control (for example a wasm trap, a missing
* worker file, or a remote sandbox that failed).
*/
| "sandbox";
export interface CodemodeError {
kind: CodemodeErrorKind;
name?: string;
message: string;
stack?: string;
}
/** Keys the script changed with `store()`. Only successful executions report writes. */
export interface CodemodeStoreWrites {
set: Record<string, unknown>;
/** Keys stored as `undefined`. */
delete: string[];
}
/**
* `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`.
* `during`: in a {@link CodemodeSandboxBase.start} run that the script itself ended (it returned, threw, or exited),
* the result being processed when it did.
*/
export type CodemodeResult =
| {
ok: true;
value: unknown;
output: CodemodeOutputItem[];
calls: CodemodeCall[];
storeWrites: CodemodeStoreWrites;
during?: CodemodeDuring;
}
| {
ok: false;
error: CodemodeError;
output: CodemodeOutputItem[];
calls: CodemodeCall[];
during?: CodemodeDuring;
};
/** Options every sandbox accepts, wherever its VM runs. */
export interface CodemodeSandboxBaseOptions {
tools?: CodemodeTool[];
/**
* Functions exposed as top-level identifiers instead of on `tools`, for host helpers such as
* attaching an image to the result. They behave like tools (JSON round trip, promise result)
* but are not recorded in `result.calls`. Names must be identifiers and may not shadow the
* built-in globals (`tools`, `ALL_TOOLS`, `console`, `text`, `image`, `exit`, `store`, `load`).
*/
globals?: CodemodeTool[];
/**
* Overall deadline per execution, including time spent in tools. `Infinity` disables the
* deadline; the execution then only ends when the script settles or is aborted.
* Default: 300000.
*/
timeoutMs?: number;
/**
* Maximum memory the QuickJS VM may allocate. Allocations beyond it fail inside the script as
* `InternalError: out of memory`. Default: no limit beyond wasm32's 4 GiB address space.
*/
memoryLimitBytes?: number;
}
/** Options of `CodemodeSandbox`, which runs each VM in a Node or Bun worker thread. */
export interface CodemodeSandboxOptions extends CodemodeSandboxBaseOptions {
/**
* Compiled `quickjs-wasi/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
* `loadQuickJSWasm()`, the file in the installed `quickjs-wasi` package. Pass it when that file
* is not on disk, for example in a Bun compiled executable.
*/
wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>;
/**
* Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own
* worker file. Pass it when this package is bundled, since the default is resolved relative to
* the module that creates the sandbox. Bun compiled executables require the relative string
* specifier of an embedded build entrypoint; other hosts usually use a URL.
*/
workerUrl?: string | URL;
/**
* Synchronously reserves capacity immediately before creating each worker, after loading wasm.
* Throw to deny admission without starting a worker. Return a release callback, called once if
* worker construction fails or when the worker actually exits, not when the result settles.
* The release callback must not throw. Reservations can be shared across sandbox instances.
*/
acquireWorker?(): () => void;
/**
* Called once for each worker started by execute() or start(). `exited` resolves when that worker
* actually exits, which may be after the result and close() settle: on Bun a non-interruptible
* built-in can delay termination. Use it to keep the worker counted against host concurrency
* limits until it exits. A thrown callback error fails the execution and requests worker termination.
*/
onWorkerStarted?(exited: Promise<void>): void;
}
/** A call the script made in a {@link CodemodeSandboxBase.start} run, answered with `CodemodeRun.deliver()`. */
export interface CodemodeHostCall {
/** The VM's own number for the call, `1`, `2`, ... in the order the script made its calls (tools and globals). */
id: number;
target: "tool" | "global";
name: string;
/** What the script passed, after a JSON round trip; for spread globals the argument list. */
args: unknown;
}
/** A tool as a {@link CodemodeSandboxBase.start} run's script sees it: `tools.<name>` and its `ALL_TOOLS` entry. */
export interface CodemodeDeclaredTool {
name: string;
/**
* The identifier the script uses, `tools.<jsName>`. Default: `toCodemodeIdentifier(name)`. A host that replays a run
* passes the one the run started with, so a changed mapping cannot rename a tool under a resumed script.
*/
jsName?: string;
description?: string;
}
/** A global as a {@link CodemodeSandboxBase.start} run's script sees it; the same names as `globals` allow. */
export interface CodemodeDeclaredGlobal {
name: string;
spread?: boolean;
}
/**
* A call's answer. A rejection becomes an `Error` with `message` in the script, carrying the own properties of
* `properties` (JSON values; `name`, `message`, and `stack` are ignored), for example `{ structuredOutput }`.
*/
export type CodemodeOutcome =
| { ok: true; value: unknown }
| { ok: false; message: string; properties?: Record<string, unknown> };
/** In a {@link CodemodeSandboxBase.start} run: the result the VM was processing when the script finished, 0 for its first run, and the calls issued by then. */
export interface CodemodeDuring {
seq: number;
issued: number;
}
export interface CodemodeRunOptions {
/** The VM clock's start, in ms since the epoch: `Date.now()` until the first result arrives. */
startedAt: number;
/** Seeds `Math.random()` and the VM's other randomness. A 32-bit unsigned integer. */
seed: number;
tools?: CodemodeDeclaredTool[];
globals?: CodemodeDeclaredGlobal[];
store?: Readonly<Record<string, unknown>>;
signal?: AbortSignal;
/** Deadline of this run in ms. Default: none (not the sandbox's default). */
timeoutMs?: number;
/** The VM's fingerprint, before the script runs and before any of its calls. */
onStarted?(fingerprint: string): void;
/** The script made a call. Answer it with `CodemodeRun.deliver()`, now or later. */
onCall(call: CodemodeHostCall): void;
/**
* The VM processed result `seq` (`0`: the script's first run, before any result) and waits again, having issued
* `issued` calls in total.
*/
onIdle?(seq: number, issued: number): void;
}
/** A running {@link CodemodeSandboxBase.start} execution. */
export interface CodemodeRun {
readonly result: Promise<CodemodeResult>;
/**
* Whether the run has its outcome: the script ended, timed out, or failed, or the run was aborted. Set when that is
* decided, before `result` settles; calls can no longer be answered. It does not mean the VM has exited.
*/
readonly finished: boolean;
/**
* Answer call `id` as result number `seq`. Results are numbered 1, 2, ... without gaps, and the VM applies them
* strictly in that order, whatever order they arrive in: after a gap it waits for the missing number. While it
* processes this one, its clock reads `now` (ms since the epoch), or the previous value if `now` is earlier.
* Throws for a call that is not waiting for an answer, a `seq` used before, or a `now` that is not finite.
*/
deliver(seq: number, now: number, id: number, outcome: CodemodeOutcome): void;
}
export interface CodemodeExecuteOptions {
signal?: AbortSignal;
/** Overrides the sandbox default for this execution. */
timeoutMs?: number;
/**
* Values the script reads with `load(key)`. Must be JSON-serializable. The script's own
* `store()` calls come back as `result.storeWrites`; persisting them is up to the caller.
*/
store?: Readonly<Record<string, unknown>>;
}

View File

@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
/**
* A compiled `WebAssembly.Module` of `quickjs-wasi/quickjs.wasm`. Typed opaquely because the Node
* type definitions do not declare the WebAssembly globals (they live in the DOM lib).
*/
export type CodemodeWasmModule = object;
interface WebAssemblyGlobal {
compile(bytes: Uint8Array): Promise<CodemodeWasmModule>;
}
const modules = new Map<string, Promise<CodemodeWasmModule>>();
/**
* Read and compile the QuickJS wasm once per path. `path` defaults to the file in the installed
* `quickjs-wasi` package; pass it when that file lives elsewhere, for example embedded in a Bun
* compiled executable. A failed load is retried on the next call.
*/
export function loadQuickJSWasm(path?: string): Promise<CodemodeWasmModule> {
const resolved = path ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
let module = modules.get(resolved);
if (!module) {
const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };
module = readFile(resolved)
.then((bytes) => WebAssembly.compile(bytes))
.catch((error: unknown) => {
modules.delete(resolved);
throw error;
});
modules.set(resolved, module);
}
return module;
}