From c4078e60fdbb1726a511d66934fe6ca40e1e06f1 Mon Sep 17 00:00:00 2001 From: HeavenllyDemon <160774215+HeavenllyDemon@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:25:40 +0100 Subject: [PATCH 1/2] [01/05] feat(core): add bounded functions exec foundations --- packages/core/src/functions-exec/ndjson.ts | 149 +++++++++++ packages/core/src/functions-exec/protocol.ts | 237 ++++++++++++++++++ packages/core/src/functions-exec/retention.ts | 93 +++++++ .../core/src/functions-exec/worker-api.ts | 147 +++++++++++ .../core/test/functions-exec/ndjson.test.ts | 49 ++++ .../core/test/functions-exec/protocol.test.ts | 27 ++ .../test/functions-exec/retention.test.ts | 35 +++ .../test/functions-exec/worker-api.test.ts | 44 ++++ 8 files changed, 781 insertions(+) create mode 100644 packages/core/src/functions-exec/ndjson.ts create mode 100644 packages/core/src/functions-exec/protocol.ts create mode 100644 packages/core/src/functions-exec/retention.ts create mode 100644 packages/core/src/functions-exec/worker-api.ts create mode 100644 packages/core/test/functions-exec/ndjson.test.ts create mode 100644 packages/core/test/functions-exec/protocol.test.ts create mode 100644 packages/core/test/functions-exec/retention.test.ts create mode 100644 packages/core/test/functions-exec/worker-api.test.ts diff --git a/packages/core/src/functions-exec/ndjson.ts b/packages/core/src/functions-exec/ndjson.ts new file mode 100644 index 00000000..db95731a --- /dev/null +++ b/packages/core/src/functions-exec/ndjson.ts @@ -0,0 +1,149 @@ +import { + MAX_CONTEXT_ENVELOPE_BYTES, + assertParentToWorkerFrame, + assertWireFrame, + type FunctionsExecFatalCode, + type ParentToWorkerFrame, + type WorkerToParentFrame, +} from "./protocol"; + +export type { ParentToWorkerFrame, WorkerToParentFrame } from "./protocol"; + +export const MAX_NDJSON_LINE_BYTES = MAX_CONTEXT_ENVELOPE_BYTES; +export const MAX_NDJSON_TOTAL_BYTES = MAX_NDJSON_LINE_BYTES * 8; + +export interface NdjsonLimits { + maxLineBytes?: number; + maxTotalBytes?: number; +} + +export interface NdjsonFatalError { + code: FunctionsExecFatalCode; + message: string; +} + +export type NdjsonDecodeResult = + | { ok: true; frames: T[] } + | { ok: false; fatal: NdjsonFatalError }; + +export type FrameValidator = (value: unknown) => T; + +type ParsedFrame = { ok: true; frame: T } | { ok: false; fatal: NdjsonFatalError }; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); + +function error(code: FunctionsExecFatalCode, message: string): NdjsonDecodeResult { + return { ok: false, fatal: { code, message } }; +} + +function positiveLimit(value: number | undefined, fallback: number, maximum: number, name: string): number { + const limit = value ?? fallback; + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > maximum) throw new RangeError(`${name} must be a positive integer no greater than ${maximum}`); + return limit; +} + +export function encodeNdjsonFrame(frame: ParentToWorkerFrame | WorkerToParentFrame): Uint8Array { + const validated = assertWireFrame(frame); + const line = encoder.encode(`${JSON.stringify(validated)}\n`); + if (line.byteLength - 1 > MAX_NDJSON_LINE_BYTES) throw new RangeError("NDJSON frame exceeds the line limit"); + return line; +} + +/** Incremental byte framer: it scans for newlines before decoding, so a multibyte UTF-8 character + * split between chunks is reconstructed without accepting malformed UTF-8 or buffering an + * unbounded line. */ +export class NdjsonDecoder { + private readonly validate: FrameValidator; + private readonly maxLineBytes: number; + private readonly maxTotalBytes: number; + private pending = new Uint8Array(); + private totalBytes = 0; + private terminal: NdjsonFatalError | "finished" | undefined; + + constructor(options?: NdjsonLimits); + constructor(validate: FrameValidator, options?: NdjsonLimits); + constructor(input?: NdjsonLimits | FrameValidator, options?: NdjsonLimits) { + const isValidator = typeof input === "function"; + this.validate = (isValidator ? input : assertParentToWorkerFrame) as FrameValidator; + const limits = (isValidator ? options : input) as NdjsonLimits | undefined; + this.maxLineBytes = positiveLimit(limits?.maxLineBytes, MAX_NDJSON_LINE_BYTES, MAX_NDJSON_LINE_BYTES, "maxLineBytes"); + this.maxTotalBytes = positiveLimit(limits?.maxTotalBytes, MAX_NDJSON_TOTAL_BYTES, MAX_NDJSON_TOTAL_BYTES, "maxTotalBytes"); + } + + push(chunk: Uint8Array): NdjsonDecodeResult { + if (this.terminal) return this.terminal === "finished" ? error("stream_closed", "NDJSON stream is already closed") : { ok: false, fatal: this.terminal }; + this.totalBytes += chunk.byteLength; + if (this.totalBytes > this.maxTotalBytes) return this.fail("output_limit_exceeded", "NDJSON output exceeded its total byte limit"); + + const frames: T[] = []; + let start = 0; + for (let index = 0; index < chunk.byteLength; index += 1) { + if (chunk[index] !== 0x0a) continue; + const appended = this.append(chunk.subarray(start, index)); + if (appended) return appended; + const line = this.takeLine(); + start = index + 1; + if (line.byteLength === 0) continue; + const parsed = this.parseLine(line); + if (!parsed.ok) return parsed; + frames.push(parsed.frame); + } + const appended = this.append(chunk.subarray(start)); + if (appended) return appended; + return { ok: true, frames }; + } + + finish(reason: "eof" | "epipe" = "eof"): NdjsonDecodeResult { + if (this.terminal) return this.terminal === "finished" ? { ok: true, frames: [] } : { ok: false, fatal: this.terminal }; + if (reason === "epipe") return this.fail("broken_pipe", "NDJSON pipe closed before completion"); + if (this.pending.byteLength !== 0) return this.fail("truncated_line", "NDJSON stream ended with a partial line"); + this.terminal = "finished"; + return { ok: true, frames: [] }; + } + + private append(bytes: Uint8Array): NdjsonDecodeResult | undefined { + if (bytes.byteLength === 0) return undefined; + if (this.pending.byteLength + bytes.byteLength > this.maxLineBytes) { + return this.fail("line_too_large", "NDJSON line exceeded its byte limit"); + } + const next = new Uint8Array(this.pending.byteLength + bytes.byteLength); + next.set(this.pending); + next.set(bytes, this.pending.byteLength); + this.pending = next; + return undefined; + } + + private takeLine(): Uint8Array { + const line = this.pending; + this.pending = new Uint8Array(); + return line.byteLength > 0 && line[line.byteLength - 1] === 0x0d ? line.slice(0, -1) : line; + } + + private parseLine(line: Uint8Array): ParsedFrame { + let decoded: string; + try { + decoded = decoder.decode(line); + } catch { + return this.fail("invalid_utf8", "NDJSON line is not valid UTF-8"); + } + let json: unknown; + try { + json = JSON.parse(decoded); + } catch { + return this.fail("invalid_json", "NDJSON line is not valid JSON"); + } + try { + return { ok: true, frame: this.validate(json) }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : "NDJSON frame is invalid"; + return this.fail("invalid_frame", message); + } + } + + private fail(code: FunctionsExecFatalCode, message: string): { ok: false; fatal: NdjsonFatalError } { + this.terminal = { code, message }; + this.pending = new Uint8Array(); + return { ok: false, fatal: this.terminal }; + } +} diff --git a/packages/core/src/functions-exec/protocol.ts b/packages/core/src/functions-exec/protocol.ts new file mode 100644 index 00000000..d6a1d984 --- /dev/null +++ b/packages/core/src/functions-exec/protocol.ts @@ -0,0 +1,237 @@ +/** + * The functions-exec wire is deliberately tiny. A future model-context fragment may be made of + * any one of these envelopes, so its complete UTF-8 representation is capped at 512 bytes. This + * is a conservative worst-case token guard: a byte-oriented tokenizer cannot require more than + * one token per UTF-8 byte, leaving the whole fragment well below one thousand tokens. + */ +export const MAX_CONTEXT_ENVELOPE_BYTES = 512; +export const MAX_UNTRUSTED_STRING_CHARS = 256; +export const MAX_UNTRUSTED_STRING_BYTES = 384; +export const MAX_JSON_DEPTH = 4; +export const MAX_JSON_CONTAINER_ENTRIES = 16; +export const MAX_CELL_ID_CHARS = 64; + +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export enum ImageDetail { + Auto = "auto", + Low = "low", + High = "high", + Original = "original", +} + +export type CellFrame = + | { type: "text"; text: string } + | { type: "image"; dataUrl: string; detail: ImageDetail } + | { type: "audio"; dataUrl: string } + | { type: "notification"; text: string } + | { type: "yield" } + | { type: "error"; code: string; message: string }; + +export type NestedToolName = "bash" | "edit" | "read" | "web_fetch" | "web_search"; + +export type FunctionsExecFatalCode = + | "invalid_utf8" + | "invalid_json" + | "invalid_frame" + | "line_too_large" + | "output_limit_exceeded" + | "truncated_line" + | "broken_pipe" + | "stream_closed"; + +export type ParentToWorkerFrame = + | { type: "execute"; cellId: string; source: string } + | { type: "tool_result"; cellId: string; callId: string; result: JsonValue; isError: boolean } + | { type: "abort"; cellId: string } + | { type: "shutdown" }; + +export type WorkerToParentFrame = + | { type: "ready" } + | { type: "call"; cellId: string; callId: string; name: NestedToolName; args: JsonValue } + | { type: "cell"; cellId: string; frame: CellFrame } + | { type: "completed"; cellId: string } + | { type: "fatal"; code: FunctionsExecFatalCode; message: string }; + +const textEncoder = new TextEncoder(); +const nestedToolNames = new Set(["bash", "edit", "read", "web_fetch", "web_search"]); +const fatalCodes = new Set([ + "invalid_utf8", + "invalid_json", + "invalid_frame", + "line_too_large", + "output_limit_exceeded", + "truncated_line", + "broken_pipe", + "stream_closed", +]); + +function fail(message: string): never { + throw new TypeError(`Invalid functions-exec frame: ${message}`); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertExactKeys(value: Record, keys: readonly string[]): void { + const actual = Object.keys(value); + if (actual.length !== keys.length || actual.some((key) => !keys.includes(key))) fail("unknown or missing key"); +} + +export function assertUntrustedString(value: unknown, label = "string"): asserts value is string { + if (typeof value !== "string") fail(`${label} must be a string`); + if (value.length > MAX_UNTRUSTED_STRING_CHARS || textEncoder.encode(value).byteLength > MAX_UNTRUSTED_STRING_BYTES) { + fail(`${label} string exceeds its hard bound`); + } +} + +function assertIdentifier(value: unknown, label: string): asserts value is string { + assertUntrustedString(value, label); + if (value.length === 0 || value.length > MAX_CELL_ID_CHARS || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) { + fail(`${label} must be a compact identifier`); + } +} + +export function assertJsonValue(value: unknown, depth = 0): asserts value is JsonValue { + if (value === null || typeof value === "boolean") return; + if (typeof value === "number") { + if (!Number.isFinite(value)) fail("JSON numbers must be finite"); + return; + } + if (typeof value === "string") { + assertUntrustedString(value, "JSON value"); + return; + } + if (depth >= MAX_JSON_DEPTH) fail("JSON value exceeds maximum depth"); + if (Array.isArray(value)) { + if (value.length > MAX_JSON_CONTAINER_ENTRIES) fail("JSON array has too many entries"); + for (const entry of value) assertJsonValue(entry, depth + 1); + return; + } + if (!isPlainRecord(value)) fail("value is not JSON"); + const entries = Object.entries(value); + if (entries.length > MAX_JSON_CONTAINER_ENTRIES) fail("JSON object has too many entries"); + for (const [key, entry] of entries) { + assertUntrustedString(key, "JSON key"); + assertJsonValue(entry, depth + 1); + } +} + +function encodeJson(value: unknown): Uint8Array { + assertJsonValue(value); + const encoded = JSON.stringify(value); + if (encoded === undefined) fail("value is not JSON"); + return textEncoder.encode(encoded); +} + +export function encodedEnvelopeBytes(value: unknown): number { + return encodeJson(value).byteLength; +} + +export function assertContextEnvelope(value: T): T { + if (encodedEnvelopeBytes(value) > MAX_CONTEXT_ENVELOPE_BYTES) fail("aggregate envelope exceeds its hard bound"); + return value; +} + +export function assertCellFrame(value: unknown): CellFrame { + if (!isPlainRecord(value) || typeof value.type !== "string") fail("cell frame must be an object"); + switch (value.type) { + case "text": + case "notification": { + assertExactKeys(value, ["type", "text"]); + assertUntrustedString(value.text, "cell text"); + return assertContextEnvelope(value as CellFrame); + } + case "image": { + assertExactKeys(value, ["type", "dataUrl", "detail"]); + assertUntrustedString(value.dataUrl, "image data URL"); + if (!Object.values(ImageDetail).includes(value.detail as ImageDetail)) fail("image detail is invalid"); + return assertContextEnvelope(value as CellFrame); + } + case "audio": { + assertExactKeys(value, ["type", "dataUrl"]); + assertUntrustedString(value.dataUrl, "audio data URL"); + return assertContextEnvelope(value as CellFrame); + } + case "yield": + assertExactKeys(value, ["type"]); + return assertContextEnvelope(value as CellFrame); + case "error": { + assertExactKeys(value, ["type", "code", "message"]); + assertIdentifier(value.code, "error code"); + assertUntrustedString(value.message, "error message"); + return assertContextEnvelope(value as CellFrame); + } + default: + fail("cell frame type is invalid"); + } +} + +export function assertParentToWorkerFrame(value: unknown): ParentToWorkerFrame { + if (!isPlainRecord(value) || typeof value.type !== "string") fail("parent frame must be an object"); + switch (value.type) { + case "execute": + assertExactKeys(value, ["type", "cellId", "source"]); + assertIdentifier(value.cellId, "cellId"); + assertUntrustedString(value.source, "source"); + return assertContextEnvelope(value as ParentToWorkerFrame); + case "tool_result": + assertExactKeys(value, ["type", "cellId", "callId", "result", "isError"]); + assertIdentifier(value.cellId, "cellId"); + assertIdentifier(value.callId, "callId"); + assertJsonValue(value.result); + if (typeof value.isError !== "boolean") fail("isError must be a boolean"); + return assertContextEnvelope(value as ParentToWorkerFrame); + case "abort": + assertExactKeys(value, ["type", "cellId"]); + assertIdentifier(value.cellId, "cellId"); + return assertContextEnvelope(value as ParentToWorkerFrame); + case "shutdown": + assertExactKeys(value, ["type"]); + return assertContextEnvelope(value as ParentToWorkerFrame); + default: + fail("parent frame type is invalid"); + } +} + +export function assertWorkerToParentFrame(value: unknown): WorkerToParentFrame { + if (!isPlainRecord(value) || typeof value.type !== "string") fail("worker frame must be an object"); + switch (value.type) { + case "ready": + assertExactKeys(value, ["type"]); + return assertContextEnvelope(value as WorkerToParentFrame); + case "call": + assertExactKeys(value, ["type", "cellId", "callId", "name", "args"]); + assertIdentifier(value.cellId, "cellId"); + assertIdentifier(value.callId, "callId"); + if (!nestedToolNames.has(value.name as NestedToolName)) fail("nested tool name is invalid"); + assertJsonValue(value.args); + return assertContextEnvelope(value as WorkerToParentFrame); + case "cell": + assertExactKeys(value, ["type", "cellId", "frame"]); + assertIdentifier(value.cellId, "cellId"); + assertCellFrame(value.frame); + return assertContextEnvelope(value as WorkerToParentFrame); + case "completed": + assertExactKeys(value, ["type", "cellId"]); + assertIdentifier(value.cellId, "cellId"); + return assertContextEnvelope(value as WorkerToParentFrame); + case "fatal": + assertExactKeys(value, ["type", "code", "message"]); + if (!fatalCodes.has(value.code as FunctionsExecFatalCode)) fail("fatal code is invalid"); + assertUntrustedString(value.message, "fatal message"); + return assertContextEnvelope(value as WorkerToParentFrame); + default: + fail("worker frame type is invalid"); + } +} + +export function assertWireFrame(value: unknown): ParentToWorkerFrame | WorkerToParentFrame { + if (!isPlainRecord(value) || typeof value.type !== "string") fail("wire frame must be an object"); + if (["execute", "tool_result", "abort", "shutdown"].includes(value.type)) return assertParentToWorkerFrame(value); + return assertWorkerToParentFrame(value); +} diff --git a/packages/core/src/functions-exec/retention.ts b/packages/core/src/functions-exec/retention.ts new file mode 100644 index 00000000..118b63a0 --- /dev/null +++ b/packages/core/src/functions-exec/retention.ts @@ -0,0 +1,93 @@ +import { MAX_CONTEXT_ENVELOPE_BYTES, assertJsonValue, assertUntrustedString, type JsonValue } from "./protocol"; + +export const TRANSIENT_MEDIA_RETENTION = 0; +export const COMPLETED_CELL_FRAME_RETENTION = 0; +export const RESULT_CHECKPOINT_RETENTION = 1; +export const MAX_PENDING_NOTIFICATIONS = 4; + +const encoder = new TextEncoder(); + +function positiveOrZero(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative integer`); + return value; +} + +function bytes(value: JsonValue): number { + return encoder.encode(JSON.stringify(value)).byteLength; +} + +/** Returns a new bounded tail. Passing zero intentionally retains nothing. */ +export function retainTail(items: readonly T[], next: T, maxItems: number): T[] { + positiveOrZero(maxItems, "maxItems"); + if (maxItems === 0) return []; + return [...items.slice(-(maxItems - 1)), next]; +} + +/** + * The future engine may project text, results, and notifications into one context item. This + * shared meter makes that aggregate finite rather than treating each channel as an independent + * budget. Its default uses the same 512-byte worst-case token guard as a single wire envelope. + */ +export class CellQuota { + private readonly maxBytes: number; + private readonly maxNotifications: number; + private usedBytes = 0; + private notifications = 0; + + constructor(options?: { maxBytes?: number; maxNotifications?: number }) { + this.maxBytes = positiveOrZero(options?.maxBytes ?? MAX_CONTEXT_ENVELOPE_BYTES, "maxBytes"); + this.maxNotifications = positiveOrZero(options?.maxNotifications ?? MAX_PENDING_NOTIFICATIONS, "maxNotifications"); + } + + get remainingBytes(): number { + return this.maxBytes - this.usedBytes; + } + + get notificationCount(): number { + return this.notifications; + } + + tryConsumeText(text: string): boolean { + assertUntrustedString(text, "text"); + return this.tryConsume({ type: "text", text }, false); + } + + tryConsumeResult(result: JsonValue): boolean { + return this.tryConsume({ type: "result", result }, false); + } + + tryConsumeNotification(text: string): boolean { + assertUntrustedString(text, "notification"); + return this.tryConsume({ type: "notification", text }, true); + } + + private tryConsume(value: JsonValue, notification: boolean): boolean { + assertJsonValue(value); + if (notification && this.notifications >= this.maxNotifications) return false; + const size = bytes(value); + if (size > this.remainingBytes) return false; + this.usedBytes += size; + if (notification) this.notifications += 1; + return true; + } +} + +/** A finite delivery buffer; the comparison deliberately rejects the first item after capacity. */ +export class BoundedNotifications { + private readonly values: string[] = []; + + constructor(private readonly capacity = MAX_PENDING_NOTIFICATIONS) { + positiveOrZero(capacity, "capacity"); + } + + push(value: string): boolean { + assertUntrustedString(value, "notification"); + if (this.values.length >= this.capacity) return false; + this.values.push(value); + return true; + } + + drain(): string[] { + return this.values.splice(0, this.values.length); + } +} diff --git a/packages/core/src/functions-exec/worker-api.ts b/packages/core/src/functions-exec/worker-api.ts new file mode 100644 index 00000000..517b8f89 --- /dev/null +++ b/packages/core/src/functions-exec/worker-api.ts @@ -0,0 +1,147 @@ +import { + ImageDetail, + MAX_CONTEXT_ENVELOPE_BYTES, + assertCellFrame, + assertContextEnvelope, + assertJsonValue, + assertUntrustedString, + type CellFrame, + type JsonValue, +} from "./protocol"; +import { CellQuota } from "./retention"; + +export { ImageDetail } from "./protocol"; + +export const MAX_STORE_KEY_CHARS = 64; +export const MAX_STORE_ENTRIES = 16; +export const MAX_MEDIA_DATA_URL_BYTES = 256; + +export interface ImageInput { + image_url: string; + detail?: ImageDetail | "auto" | "low" | "high" | "original"; +} + +export interface AudioInput { + audio_url: string; +} + +export interface WorkerHelpers { + text(value: string): void; + image(value: string | ImageInput): void; + audio(value: string | AudioInput): void; + notify(value: string): void; + store(key: string, value: JsonValue): void; + load(key: string): JsonValue | undefined; + yield(): Promise; +} + +export interface WorkerHelperOptions { + emit(frame: CellFrame): void; + onYield?(): void | Promise; + quota?: CellQuota; +} + +const encoder = new TextEncoder(); +const imageMimes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); +const audioMimes = new Set(["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"]); +const base64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const mediaDataUrl = /^data:(image\/[a-z0-9.+-]+|audio\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/i; + +function clone(value: JsonValue): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +function hasDurableMedia(value: JsonValue): boolean { + if (typeof value === "string") return /^\s*data:(?:image|audio)\//i.test(value); + if (value === null || typeof value !== "object") return false; + return Object.values(value).some(hasDurableMedia); +} + +function assertStoreKey(key: unknown): asserts key is string { + assertUntrustedString(key, "store key"); + if (key.length === 0 || key.length > MAX_STORE_KEY_CHARS) throw new TypeError("Invalid functions-exec store key"); +} + +function parseMediaDataUrl(value: unknown, allowedMimes: ReadonlySet, kind: "image" | "audio"): string { + assertUntrustedString(value, `${kind} data URL`); + if (encoder.encode(value).byteLength > MAX_MEDIA_DATA_URL_BYTES) throw new TypeError(`Invalid functions-exec ${kind}: data URL exceeds its hard bound`); + const match = mediaDataUrl.exec(value); + if (!match || !match[1] || !match[2] || !allowedMimes.has(match[1].toLowerCase()) || !base64.test(match[2])) { + throw new TypeError(`Invalid functions-exec ${kind} data URL or base64 payload`); + } + return value; +} + +function imageDetail(value: unknown): ImageDetail { + if (value === undefined) return ImageDetail.Auto; + if (!Object.values(ImageDetail).includes(value as ImageDetail)) throw new TypeError("Invalid functions-exec image detail"); + return value as ImageDetail; +} + +function emitBounded(options: WorkerHelperOptions, quota: CellQuota, frame: CellFrame): void { + const validated = assertCellFrame(frame); + const consumed = validated.type === "notification" + ? quota.tryConsumeNotification(validated.text) + : quota.tryConsumeResult(validated); + if (!consumed) throw new RangeError("functions-exec cell aggregate quota exceeded"); + options.emit(validated); +} + +function normalizeImage(value: string | ImageInput): CellFrame { + if (typeof value === "string") return { type: "image", dataUrl: parseMediaDataUrl(value, imageMimes, "image"), detail: ImageDetail.Auto }; + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "image_url" && key !== "detail")) { + throw new TypeError("Invalid functions-exec image argument"); + } + return { type: "image", dataUrl: parseMediaDataUrl(value.image_url, imageMimes, "image"), detail: imageDetail(value.detail) }; +} + +function normalizeAudio(value: string | AudioInput): CellFrame { + if (typeof value === "string") return { type: "audio", dataUrl: parseMediaDataUrl(value, audioMimes, "audio") }; + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "audio_url")) { + throw new TypeError("Invalid functions-exec audio argument"); + } + return { type: "audio", dataUrl: parseMediaDataUrl(value.audio_url, audioMimes, "audio") }; +} + +/** + * Pure worker-facing helpers. They have no filesystem, network, subprocess, or host-tool access; + * a later sandbox may inject this object as the only bridge from untrusted JavaScript. + */ +export function createWorkerHelpers(options: WorkerHelperOptions): WorkerHelpers { + const values = new Map(); + const quota = options.quota ?? new CellQuota({ maxBytes: MAX_CONTEXT_ENVELOPE_BYTES }); + + return { + text(value): void { + assertUntrustedString(value, "text"); + emitBounded(options, quota, { type: "text", text: value }); + }, + image(value): void { + emitBounded(options, quota, normalizeImage(value)); + }, + audio(value): void { + emitBounded(options, quota, normalizeAudio(value)); + }, + notify(value): void { + assertUntrustedString(value, "notification"); + emitBounded(options, quota, { type: "notification", text: value }); + }, + store(key, value): void { + assertStoreKey(key); + assertJsonValue(value); + if (hasDurableMedia(value)) throw new TypeError("functions-exec store rejects media data URLs"); + assertContextEnvelope({ type: "store", key, value }); + if (!values.has(key) && values.size >= MAX_STORE_ENTRIES) throw new RangeError("functions-exec store is full"); + values.set(key, clone(value)); + }, + load(key): JsonValue | undefined { + assertStoreKey(key); + const value = values.get(key); + return value === undefined ? undefined : clone(value); + }, + async yield(): Promise { + await options.onYield?.(); + emitBounded(options, quota, { type: "yield" }); + }, + }; +} diff --git a/packages/core/test/functions-exec/ndjson.test.ts b/packages/core/test/functions-exec/ndjson.test.ts new file mode 100644 index 00000000..a215ae62 --- /dev/null +++ b/packages/core/test/functions-exec/ndjson.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { + NdjsonDecoder, + encodeNdjsonFrame, + type ParentToWorkerFrame, +} from "../../src/functions-exec/ndjson"; + +const frame: ParentToWorkerFrame = { type: "execute", cellId: "cell-1", source: "text('é')" }; + +function fatalCode(result: ReturnType): string | undefined { + return result.ok ? undefined : result.fatal.code; +} + +describe("functions-exec NDJSON framing", () => { + test("round-trips UTF-8 split across chunks", () => { + const encoded = encodeNdjsonFrame(frame); + const split = encoded.indexOf(0xc3) + 1; + const decoder = new NdjsonDecoder(); + + expect(decoder.push(encoded.slice(0, split))).toEqual({ ok: true, frames: [] }); + expect(decoder.push(encoded.slice(split))).toEqual({ ok: true, frames: [frame] }); + expect(decoder.finish()).toEqual({ ok: true, frames: [] }); + }); + + test("rejects malformed json, malformed UTF-8, oversized lines, and excessive total output", () => { + const malformed = new NdjsonDecoder(); + expect(fatalCode(malformed.push(new TextEncoder().encode("{not json}\n")))).toBe("invalid_json"); + + const utf8 = new NdjsonDecoder(); + expect(fatalCode(utf8.push(new Uint8Array([0xff, 0x0a])))).toBe("invalid_utf8"); + + const tooLong = new NdjsonDecoder({ maxLineBytes: 8 }); + expect(fatalCode(tooLong.push(new TextEncoder().encode("123456789\n")))).toBe("line_too_large"); + + const total = new NdjsonDecoder({ maxTotalBytes: 8 }); + expect(fatalCode(total.push(new TextEncoder().encode("123456789")))).toBe("output_limit_exceeded"); + + expect(() => new NdjsonDecoder({ maxLineBytes: Number.MAX_SAFE_INTEGER })).toThrow(/no greater/i); + }); + + test("gives typed early EOF and EPIPE failures", () => { + const eof = new NdjsonDecoder(); + eof.push(new TextEncoder().encode('{"type":"abort"')); + expect(eof.finish()).toEqual({ ok: false, fatal: { code: "truncated_line", message: "NDJSON stream ended with a partial line" } }); + + const epipe = new NdjsonDecoder(); + expect(epipe.finish("epipe")).toEqual({ ok: false, fatal: { code: "broken_pipe", message: "NDJSON pipe closed before completion" } }); + }); +}); diff --git a/packages/core/test/functions-exec/protocol.test.ts b/packages/core/test/functions-exec/protocol.test.ts new file mode 100644 index 00000000..ec20047a --- /dev/null +++ b/packages/core/test/functions-exec/protocol.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_CONTEXT_ENVELOPE_BYTES, + MAX_UNTRUSTED_STRING_CHARS, + assertParentToWorkerFrame, + assertWorkerToParentFrame, + encodedEnvelopeBytes, +} from "../../src/functions-exec/protocol"; + +describe("functions-exec protocol bounds", () => { + test("accepts strict parent and worker envelopes below the single aggregate context cap", () => { + const parent = assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "text('ok')" }); + const worker = assertWorkerToParentFrame({ type: "cell", cellId: "cell-1", frame: { type: "text", text: "ok" } }); + + expect(parent).toEqual({ type: "execute", cellId: "cell-1", source: "text('ok')" }); + expect(worker).toEqual({ type: "cell", cellId: "cell-1", frame: { type: "text", text: "ok" } }); + expect(encodedEnvelopeBytes(worker)).toBeLessThanOrEqual(MAX_CONTEXT_ENVELOPE_BYTES); + }); + + test("rejects non-JSON values, unknown keys, deep arguments, and aggregate envelopes over the cap", () => { + expect(() => assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "ok", extra: true })).toThrow(/unknown/i); + expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { value: undefined } })).toThrow(/JSON/i); + expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { a: { b: { c: { d: { e: "too deep" } } } } } })).toThrow(/depth/i); + expect(() => assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "x".repeat(MAX_UNTRUSTED_STRING_CHARS + 1) })).toThrow(/string/i); + expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { first: "x".repeat(250), second: "y".repeat(250) } })).toThrow(/envelope/i); + }); +}); diff --git a/packages/core/test/functions-exec/retention.test.ts b/packages/core/test/functions-exec/retention.test.ts new file mode 100644 index 00000000..7ac4c198 --- /dev/null +++ b/packages/core/test/functions-exec/retention.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { + COMPLETED_CELL_FRAME_RETENTION, + TRANSIENT_MEDIA_RETENTION, + BoundedNotifications, + CellQuota, + retainTail, +} from "../../src/functions-exec/retention"; + +describe("functions-exec finite retention", () => { + test("keeps media and completed frames at explicit zero retention", () => { + expect(TRANSIENT_MEDIA_RETENTION).toBe(0); + expect(COMPLETED_CELL_FRAME_RETENTION).toBe(0); + expect(retainTail(["old"], "new", 0)).toEqual([]); + }); + + test("delivers exactly its notification capacity with no off-by-one", () => { + const notifications = new BoundedNotifications(2); + expect(notifications.push("first")).toBe(true); + expect(notifications.push("second")).toBe(true); + expect(notifications.push("third")).toBe(false); + expect(notifications.drain()).toEqual(["first", "second"]); + expect(notifications.drain()).toEqual([]); + expect(() => notifications.push("x".repeat(257))).toThrow(/string/i); + }); + + test("enforces one aggregate context budget across text, results, and notifications", () => { + const quota = new CellQuota({ maxBytes: 160, maxNotifications: 1 }); + expect(quota.tryConsumeText("small text")).toBe(true); + expect(quota.tryConsumeResult({ answer: "small result" })).toBe(true); + expect(quota.tryConsumeNotification("first")).toBe(true); + expect(quota.tryConsumeNotification("second")).toBe(false); + expect(quota.tryConsumeText("x".repeat(160))).toBe(false); + }); +}); diff --git a/packages/core/test/functions-exec/worker-api.test.ts b/packages/core/test/functions-exec/worker-api.test.ts new file mode 100644 index 00000000..bb769997 --- /dev/null +++ b/packages/core/test/functions-exec/worker-api.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { ImageDetail, createWorkerHelpers } from "../../src/functions-exec/worker-api"; + +describe("functions-exec worker helper API", () => { + test("emits validated text, image, audio, notification, and yield frames", async () => { + const frames: unknown[] = []; + const helpers = createWorkerHelpers({ emit: (frame) => frames.push(frame) }); + const image = `data:image/png;base64,${Buffer.from("image").toString("base64")}`; + const audio = `data:audio/mpeg;base64,${Buffer.from("audio").toString("base64")}`; + + helpers.text("hello"); + helpers.image({ image_url: image, detail: "high" }); + helpers.audio({ audio_url: audio }); + helpers.notify("finished"); + await helpers.yield(); + + expect(frames).toEqual([ + { type: "text", text: "hello" }, + { type: "image", dataUrl: image, detail: ImageDetail.High }, + { type: "audio", dataUrl: audio }, + { type: "notification", text: "finished" }, + { type: "yield" }, + ]); + }); + + test("rejects invalid helper arguments and blocks media from durable store values", () => { + const helpers = createWorkerHelpers({ emit: () => {} }); + expect(() => helpers.text({ text: "no" } as never)).toThrow(/string/i); + expect(() => helpers.image("data:text/plain;base64,eA==")).toThrow(/image/i); + expect(() => helpers.audio("data:audio/mpeg;base64,%%%%")).toThrow(/base64/i); + expect(() => helpers.store("saved", { attachment: "data:image/png;base64,aGVsbG8=" })).toThrow(/media/i); + expect(() => helpers.store("x".repeat(65), "ok")).toThrow(/key/i); + }); + + test("stores only bounded JSON values and returns an immutable copy", () => { + const helpers = createWorkerHelpers({ emit: () => {} }); + helpers.store("result", { answer: "42" }); + const loaded = helpers.load("result") as { answer: string }; + loaded.answer = "mutated"; + + expect(helpers.load("result")).toEqual({ answer: "42" }); + expect(helpers.load("missing")).toBeUndefined(); + }); +}); From 13c96f1a62f3559edff4f91beaced0ad426fa88f Mon Sep 17 00:00:00 2001 From: HeavenllyDemon <160774215+HeavenllyDemon@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:47:59 +0100 Subject: [PATCH 2/2] [01/05] feat(core): add bounded functions exec foundations --- packages/core/src/functions-exec/ndjson.ts | 41 ++++- packages/core/src/functions-exec/protocol.ts | 54 +++++- packages/core/src/functions-exec/retention.ts | 13 +- .../core/src/functions-exec/worker-api.ts | 157 ++++++++++++++---- .../core/test/functions-exec/ndjson.test.ts | 37 ++++- .../core/test/functions-exec/protocol.test.ts | 21 ++- .../test/functions-exec/retention.test.ts | 19 +++ .../test/functions-exec/worker-api.test.ts | 44 +++++ 8 files changed, 334 insertions(+), 52 deletions(-) diff --git a/packages/core/src/functions-exec/ndjson.ts b/packages/core/src/functions-exec/ndjson.ts index db95731a..65771242 100644 --- a/packages/core/src/functions-exec/ndjson.ts +++ b/packages/core/src/functions-exec/ndjson.ts @@ -1,6 +1,8 @@ import { MAX_CONTEXT_ENVELOPE_BYTES, + MAX_EXECUTE_ENVELOPE_BYTES, assertParentToWorkerFrame, + assertWorkerToParentFrame, assertWireFrame, type FunctionsExecFatalCode, type ParentToWorkerFrame, @@ -9,8 +11,11 @@ import { export type { ParentToWorkerFrame, WorkerToParentFrame } from "./protocol"; +// Worker output is model-visible, while a single parent execute frame is private source transport. export const MAX_NDJSON_LINE_BYTES = MAX_CONTEXT_ENVELOPE_BYTES; export const MAX_NDJSON_TOTAL_BYTES = MAX_NDJSON_LINE_BYTES * 8; +export const MAX_PARENT_NDJSON_LINE_BYTES = MAX_EXECUTE_ENVELOPE_BYTES; +export const MAX_PARENT_NDJSON_TOTAL_BYTES = MAX_PARENT_NDJSON_LINE_BYTES + MAX_NDJSON_LINE_BYTES * 4; export interface NdjsonLimits { maxLineBytes?: number; @@ -46,7 +51,8 @@ function positiveLimit(value: number | undefined, fallback: number, maximum: num export function encodeNdjsonFrame(frame: ParentToWorkerFrame | WorkerToParentFrame): Uint8Array { const validated = assertWireFrame(frame); const line = encoder.encode(`${JSON.stringify(validated)}\n`); - if (line.byteLength - 1 > MAX_NDJSON_LINE_BYTES) throw new RangeError("NDJSON frame exceeds the line limit"); + const maxLineBytes = isParentFrame(validated) ? MAX_PARENT_NDJSON_LINE_BYTES : MAX_NDJSON_LINE_BYTES; + if (line.byteLength - 1 > maxLineBytes) throw new RangeError("NDJSON frame exceeds the line limit"); return line; } @@ -59,6 +65,8 @@ export class NdjsonDecoder { private readonly maxTotalBytes: number; private pending = new Uint8Array(); private totalBytes = 0; + private completed = false; + private readonly requiresCompletion: boolean; private terminal: NdjsonFatalError | "finished" | undefined; constructor(options?: NdjsonLimits); @@ -67,12 +75,17 @@ export class NdjsonDecoder { const isValidator = typeof input === "function"; this.validate = (isValidator ? input : assertParentToWorkerFrame) as FrameValidator; const limits = (isValidator ? options : input) as NdjsonLimits | undefined; - this.maxLineBytes = positiveLimit(limits?.maxLineBytes, MAX_NDJSON_LINE_BYTES, MAX_NDJSON_LINE_BYTES, "maxLineBytes"); - this.maxTotalBytes = positiveLimit(limits?.maxTotalBytes, MAX_NDJSON_TOTAL_BYTES, MAX_NDJSON_TOTAL_BYTES, "maxTotalBytes"); + const parentFrames = !isValidator || input === assertParentToWorkerFrame; + const defaultLineBytes = parentFrames ? MAX_PARENT_NDJSON_LINE_BYTES : MAX_NDJSON_LINE_BYTES; + const defaultTotalBytes = parentFrames ? MAX_PARENT_NDJSON_TOTAL_BYTES : MAX_NDJSON_TOTAL_BYTES; + this.maxLineBytes = positiveLimit(limits?.maxLineBytes, defaultLineBytes, defaultLineBytes, "maxLineBytes"); + this.maxTotalBytes = positiveLimit(limits?.maxTotalBytes, defaultTotalBytes, defaultTotalBytes, "maxTotalBytes"); + this.requiresCompletion = input === assertWorkerToParentFrame; } push(chunk: Uint8Array): NdjsonDecodeResult { if (this.terminal) return this.terminal === "finished" ? error("stream_closed", "NDJSON stream is already closed") : { ok: false, fatal: this.terminal }; + if (this.completed) return this.fail("stream_closed", "NDJSON stream is already completed"); this.totalBytes += chunk.byteLength; if (this.totalBytes > this.maxTotalBytes) return this.fail("output_limit_exceeded", "NDJSON output exceeded its total byte limit"); @@ -85,9 +98,11 @@ export class NdjsonDecoder { const line = this.takeLine(); start = index + 1; if (line.byteLength === 0) continue; + if (this.completed) return this.fail("stream_closed", "NDJSON stream is already completed"); const parsed = this.parseLine(line); if (!parsed.ok) return parsed; frames.push(parsed.frame); + if (isCompletedFrame(parsed.frame)) this.completed = true; } const appended = this.append(chunk.subarray(start)); if (appended) return appended; @@ -96,15 +111,23 @@ export class NdjsonDecoder { finish(reason: "eof" | "epipe" = "eof"): NdjsonDecodeResult { if (this.terminal) return this.terminal === "finished" ? { ok: true, frames: [] } : { ok: false, fatal: this.terminal }; - if (reason === "epipe") return this.fail("broken_pipe", "NDJSON pipe closed before completion"); + if (reason === "epipe" && (!this.requiresCompletion || !this.completed)) { + return this.fail("broken_pipe", "NDJSON pipe closed before completion"); + } if (this.pending.byteLength !== 0) return this.fail("truncated_line", "NDJSON stream ended with a partial line"); + if (this.requiresCompletion && !this.completed) return this.fail("broken_pipe", "NDJSON stream ended before completion"); this.terminal = "finished"; return { ok: true, frames: [] }; } private append(bytes: Uint8Array): NdjsonDecodeResult | undefined { if (bytes.byteLength === 0) return undefined; - if (this.pending.byteLength + bytes.byteLength > this.maxLineBytes) { + const nextLength = this.pending.byteLength + bytes.byteLength; + const trailingCarriageReturn = bytes.byteLength > 0 + ? bytes[bytes.byteLength - 1] === 0x0d + : this.pending.byteLength > 0 && this.pending[this.pending.byteLength - 1] === 0x0d; + const payloadLength = nextLength - (trailingCarriageReturn ? 1 : 0); + if (payloadLength > this.maxLineBytes) { return this.fail("line_too_large", "NDJSON line exceeded its byte limit"); } const next = new Uint8Array(this.pending.byteLength + bytes.byteLength); @@ -147,3 +170,11 @@ export class NdjsonDecoder { return { ok: false, fatal: this.terminal }; } } + +function isCompletedFrame(value: unknown): boolean { + return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "completed"; +} + +function isParentFrame(frame: ParentToWorkerFrame | WorkerToParentFrame): frame is ParentToWorkerFrame { + return frame.type === "execute" || frame.type === "tool_result" || frame.type === "abort" || frame.type === "shutdown"; +} diff --git a/packages/core/src/functions-exec/protocol.ts b/packages/core/src/functions-exec/protocol.ts index d6a1d984..4d8f3dae 100644 --- a/packages/core/src/functions-exec/protocol.ts +++ b/packages/core/src/functions-exec/protocol.ts @@ -7,9 +7,17 @@ export const MAX_CONTEXT_ENVELOPE_BYTES = 512; export const MAX_UNTRUSTED_STRING_CHARS = 256; export const MAX_UNTRUSTED_STRING_BYTES = 384; +// Source is transport-only: the engine replaces it with an opaque marker before provider input or +// durable history. It still needs a meaningful but fixed budget for raw Codex patches. +export const MAX_FUNCTIONS_EXEC_SOURCE_CHARS = 8 * 1024; +export const MAX_FUNCTIONS_EXEC_SOURCE_BYTES = 8 * 1024; +// JSON can double a source made only of quotes or backslashes. This caps the execute transport +// envelope without weakening the 512-byte cap for any model-visible worker result. +export const MAX_EXECUTE_ENVELOPE_BYTES = MAX_FUNCTIONS_EXEC_SOURCE_BYTES * 2 + 256; export const MAX_JSON_DEPTH = 4; export const MAX_JSON_CONTAINER_ENTRIES = 16; export const MAX_CELL_ID_CHARS = 64; +export const MAX_MEDIA_DATA_URL_BYTES = 256; export type JsonPrimitive = null | boolean | number | string; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -55,6 +63,10 @@ export type WorkerToParentFrame = | { type: "fatal"; code: FunctionsExecFatalCode; message: string }; const textEncoder = new TextEncoder(); +const imageMimes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); +const audioMimes = new Set(["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"]); +const base64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const mediaDataUrl = /^data:(image\/[a-z0-9.+-]+|audio\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/i; const nestedToolNames = new Set(["bash", "edit", "read", "web_fetch", "web_search"]); const fatalCodes = new Set([ "invalid_utf8", @@ -89,6 +101,30 @@ export function assertUntrustedString(value: unknown, label = "string"): asserts } } +export function assertFunctionsExecSource(value: unknown): asserts value is string { + if (typeof value !== "string") fail("source must be a string"); + if (value.length > MAX_FUNCTIONS_EXEC_SOURCE_CHARS || textEncoder.encode(value).byteLength > MAX_FUNCTIONS_EXEC_SOURCE_BYTES) { + fail("source exceeds its hard bound"); + } +} + +export function assertMediaDataUrl(value: unknown, kind: "image" | "audio"): string { + assertUntrustedString(value, `${kind} data URL`); + if (textEncoder.encode(value).byteLength > MAX_MEDIA_DATA_URL_BYTES) { + fail(`${kind} data URL exceeds its hard bound`); + } + const match = mediaDataUrl.exec(value); + const allowedMimes = kind === "image" ? imageMimes : audioMimes; + if (!match || !match[1] || !match[2] || !allowedMimes.has(match[1].toLowerCase()) || !base64.test(match[2])) { + fail(`${kind} data URL or base64 payload is invalid`); + } + return value; +} + +export function assertImageDetail(value: unknown): asserts value is ImageDetail { + if (!Object.values(ImageDetail).includes(value as ImageDetail)) fail("image detail is invalid"); +} + function assertIdentifier(value: unknown, label: string): asserts value is string { assertUntrustedString(value, label); if (value.length === 0 || value.length > MAX_CELL_ID_CHARS || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) { @@ -137,6 +173,14 @@ export function assertContextEnvelope(value: T): T { return value; } +function assertExecuteEnvelope>(value: T): T { + const encoded = JSON.stringify(value); + if (encoded === undefined || textEncoder.encode(encoded).byteLength > MAX_EXECUTE_ENVELOPE_BYTES) { + fail("execute envelope exceeds its hard bound"); + } + return value; +} + export function assertCellFrame(value: unknown): CellFrame { if (!isPlainRecord(value) || typeof value.type !== "string") fail("cell frame must be an object"); switch (value.type) { @@ -148,13 +192,13 @@ export function assertCellFrame(value: unknown): CellFrame { } case "image": { assertExactKeys(value, ["type", "dataUrl", "detail"]); - assertUntrustedString(value.dataUrl, "image data URL"); - if (!Object.values(ImageDetail).includes(value.detail as ImageDetail)) fail("image detail is invalid"); + assertMediaDataUrl(value.dataUrl, "image"); + assertImageDetail(value.detail); return assertContextEnvelope(value as CellFrame); } case "audio": { assertExactKeys(value, ["type", "dataUrl"]); - assertUntrustedString(value.dataUrl, "audio data URL"); + assertMediaDataUrl(value.dataUrl, "audio"); return assertContextEnvelope(value as CellFrame); } case "yield": @@ -177,8 +221,8 @@ export function assertParentToWorkerFrame(value: unknown): ParentToWorkerFrame { case "execute": assertExactKeys(value, ["type", "cellId", "source"]); assertIdentifier(value.cellId, "cellId"); - assertUntrustedString(value.source, "source"); - return assertContextEnvelope(value as ParentToWorkerFrame); + assertFunctionsExecSource(value.source); + return assertExecuteEnvelope(value) as ParentToWorkerFrame; case "tool_result": assertExactKeys(value, ["type", "cellId", "callId", "result", "isError"]); assertIdentifier(value.cellId, "cellId"); diff --git a/packages/core/src/functions-exec/retention.ts b/packages/core/src/functions-exec/retention.ts index 118b63a0..c146d922 100644 --- a/packages/core/src/functions-exec/retention.ts +++ b/packages/core/src/functions-exec/retention.ts @@ -20,7 +20,8 @@ function bytes(value: JsonValue): number { export function retainTail(items: readonly T[], next: T, maxItems: number): T[] { positiveOrZero(maxItems, "maxItems"); if (maxItems === 0) return []; - return [...items.slice(-(maxItems - 1)), next]; + const retainedExisting = maxItems - 1; + return [...items.slice(items.length - retainedExisting), next]; } /** @@ -35,8 +36,14 @@ export class CellQuota { private notifications = 0; constructor(options?: { maxBytes?: number; maxNotifications?: number }) { - this.maxBytes = positiveOrZero(options?.maxBytes ?? MAX_CONTEXT_ENVELOPE_BYTES, "maxBytes"); - this.maxNotifications = positiveOrZero(options?.maxNotifications ?? MAX_PENDING_NOTIFICATIONS, "maxNotifications"); + this.maxBytes = Math.min( + positiveOrZero(options?.maxBytes ?? MAX_CONTEXT_ENVELOPE_BYTES, "maxBytes"), + MAX_CONTEXT_ENVELOPE_BYTES, + ); + this.maxNotifications = Math.min( + positiveOrZero(options?.maxNotifications ?? MAX_PENDING_NOTIFICATIONS, "maxNotifications"), + MAX_PENDING_NOTIFICATIONS, + ); } get remainingBytes(): number { diff --git a/packages/core/src/functions-exec/worker-api.ts b/packages/core/src/functions-exec/worker-api.ts index 517b8f89..c1e3ce4b 100644 --- a/packages/core/src/functions-exec/worker-api.ts +++ b/packages/core/src/functions-exec/worker-api.ts @@ -1,20 +1,23 @@ import { ImageDetail, MAX_CONTEXT_ENVELOPE_BYTES, + MAX_JSON_CONTAINER_ENTRIES, + MAX_JSON_DEPTH, + MAX_MEDIA_DATA_URL_BYTES, assertCellFrame, assertContextEnvelope, - assertJsonValue, + assertImageDetail, + assertMediaDataUrl, assertUntrustedString, type CellFrame, type JsonValue, } from "./protocol"; import { CellQuota } from "./retention"; -export { ImageDetail } from "./protocol"; +export { ImageDetail, MAX_MEDIA_DATA_URL_BYTES } from "./protocol"; export const MAX_STORE_KEY_CHARS = 64; export const MAX_STORE_ENTRIES = 16; -export const MAX_MEDIA_DATA_URL_BYTES = 256; export interface ImageInput { image_url: string; @@ -30,7 +33,7 @@ export interface WorkerHelpers { image(value: string | ImageInput): void; audio(value: string | AudioInput): void; notify(value: string): void; - store(key: string, value: JsonValue): void; + store(key: string, value: unknown): void; load(key: string): JsonValue | undefined; yield(): Promise; } @@ -41,40 +44,125 @@ export interface WorkerHelperOptions { quota?: CellQuota; } -const encoder = new TextEncoder(); -const imageMimes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); -const audioMimes = new Set(["audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4"]); -const base64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; -const mediaDataUrl = /^data:(image\/[a-z0-9.+-]+|audio\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/i; +const own = Object.prototype.hasOwnProperty; + +function canonicalMediaCandidate(value: string): string { + let candidate = value.normalize("NFKC").trim(); + for (let iteration = 0; iteration <= value.length; iteration += 1) { + const escapedCandidate = candidate.replace(/\\\\+/g, "\\"); + const decodedEscapes = escapedCandidate.replace(/\\u\{([0-9a-f]{1,6})\}|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})/gi, (_match, codePoint: string | undefined, codeUnit: string | undefined, byte: string | undefined) => { + const code = Number.parseInt(codePoint ?? codeUnit ?? byte ?? "", 16); + return Number.isSafeInteger(code) && code <= 0x10ffff ? String.fromCodePoint(code) : _match; + }); + let percentDecoded = decodedEscapes; + try { + percentDecoded = decodeURIComponent(decodedEscapes); + } catch { + // Invalid percent escapes cannot turn into a canonical data URL. + } + const next = percentDecoded.normalize("NFKC").replace(/[\u0000-\u0020]+/g, ""); + if (next === candidate) return next; + candidate = next; + } + return candidate; +} -function clone(value: JsonValue): JsonValue { - return JSON.parse(JSON.stringify(value)) as JsonValue; +function assertNoDurableMedia(value: string, label: string): void { + if (/data:(?:image|audio)\//i.test(canonicalMediaCandidate(value))) { + throw new TypeError(`functions-exec store rejects media data URLs in ${label}`); + } } -function hasDurableMedia(value: JsonValue): boolean { - if (typeof value === "string") return /^\s*data:(?:image|audio)\//i.test(value); - if (value === null || typeof value !== "object") return false; - return Object.values(value).some(hasDurableMedia); +function descriptorValue(descriptor: PropertyDescriptor, label: string): unknown { + if (!own.call(descriptor, "value")) throw new TypeError(`functions-exec store rejects accessor ${label}`); + return descriptor.value; } -function assertStoreKey(key: unknown): asserts key is string { - assertUntrustedString(key, "store key"); - if (key.length === 0 || key.length > MAX_STORE_KEY_CHARS) throw new TypeError("Invalid functions-exec store key"); +function immutable(value: JsonValue): JsonValue { + if (value !== null && typeof value === "object") Object.freeze(value); + return value; } -function parseMediaDataUrl(value: unknown, allowedMimes: ReadonlySet, kind: "image" | "audio"): string { - assertUntrustedString(value, `${kind} data URL`); - if (encoder.encode(value).byteLength > MAX_MEDIA_DATA_URL_BYTES) throw new TypeError(`Invalid functions-exec ${kind}: data URL exceeds its hard bound`); - const match = mediaDataUrl.exec(value); - if (!match || !match[1] || !match[2] || !allowedMimes.has(match[1].toLowerCase()) || !base64.test(match[2])) { - throw new TypeError(`Invalid functions-exec ${kind} data URL or base64 payload`); +function snapshotStoreValue(value: unknown, depth = 0): JsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("functions-exec store only accepts finite JSON numbers"); + return value; } - return value; + if (typeof value === "string") { + assertUntrustedString(value, "store value"); + assertNoDurableMedia(value, "store value"); + return value; + } + if (typeof value !== "object" || depth >= MAX_JSON_DEPTH) { + throw new TypeError("functions-exec store only accepts bounded JSON values"); + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError("functions-exec store rejects symbol keys"); + return Array.isArray(value) + ? snapshotArray(descriptors, depth) + : snapshotRecord(descriptors, depth); +} + +function snapshotArray(descriptors: Record, depth: number): JsonValue { + const lengthDescriptor = descriptors.length; + const length = lengthDescriptor === undefined ? undefined : descriptorValue(lengthDescriptor, "array length"); + if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0 || length > MAX_JSON_CONTAINER_ENTRIES) { + throw new TypeError("functions-exec store array has too many entries"); + } + const values: JsonValue[] = []; + for (const key of Object.keys(descriptors)) { + if (key === "length") continue; + assertUntrustedString(key, "store key"); + assertNoDurableMedia(key, "store key"); + if (!/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= length) { + throw new TypeError("functions-exec store array has an unsupported property"); + } + descriptorValue(descriptors[key] as PropertyDescriptor, `array property ${key}`); + } + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + values.push(descriptor === undefined ? null : snapshotStoreValue(descriptorValue(descriptor, `array property ${index}`), depth + 1)); + } + return immutable(values); +} + +function snapshotRecord(descriptors: Record, depth: number): JsonValue { + const keys = Object.keys(descriptors); + if (keys.length > MAX_JSON_CONTAINER_ENTRIES) throw new TypeError("functions-exec store object has too many entries"); + const snapshot = Object.create(null) as Record; + for (const key of keys) { + assertUntrustedString(key, "store key"); + assertNoDurableMedia(key, "store key"); + const descriptor = descriptors[key] as PropertyDescriptor; + const value = snapshotStoreValue(descriptorValue(descriptor, `property ${key}`), depth + 1); + if (descriptor.enumerable) { + Object.defineProperty(snapshot, key, { value, enumerable: true, configurable: false, writable: false }); + } + } + return immutable(snapshot); +} + +function cloneSnapshot(value: JsonValue): JsonValue { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(cloneSnapshot); + const copy: Record = {}; + for (const key of Object.keys(value)) { + Object.defineProperty(copy, key, { value: cloneSnapshot(value[key] as JsonValue), enumerable: true, configurable: true, writable: true }); + } + return copy; +} + +function assertStoreKey(key: unknown): asserts key is string { + assertUntrustedString(key, "store key"); + if (key.length === 0 || key.length > MAX_STORE_KEY_CHARS) throw new TypeError("Invalid functions-exec store key"); + assertNoDurableMedia(key, "store key"); } function imageDetail(value: unknown): ImageDetail { if (value === undefined) return ImageDetail.Auto; - if (!Object.values(ImageDetail).includes(value as ImageDetail)) throw new TypeError("Invalid functions-exec image detail"); + assertImageDetail(value); return value as ImageDetail; } @@ -88,19 +176,19 @@ function emitBounded(options: WorkerHelperOptions, quota: CellQuota, frame: Cell } function normalizeImage(value: string | ImageInput): CellFrame { - if (typeof value === "string") return { type: "image", dataUrl: parseMediaDataUrl(value, imageMimes, "image"), detail: ImageDetail.Auto }; + if (typeof value === "string") return { type: "image", dataUrl: assertMediaDataUrl(value, "image"), detail: ImageDetail.Auto }; if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "image_url" && key !== "detail")) { throw new TypeError("Invalid functions-exec image argument"); } - return { type: "image", dataUrl: parseMediaDataUrl(value.image_url, imageMimes, "image"), detail: imageDetail(value.detail) }; + return { type: "image", dataUrl: assertMediaDataUrl(value.image_url, "image"), detail: imageDetail(value.detail) }; } function normalizeAudio(value: string | AudioInput): CellFrame { - if (typeof value === "string") return { type: "audio", dataUrl: parseMediaDataUrl(value, audioMimes, "audio") }; + if (typeof value === "string") return { type: "audio", dataUrl: assertMediaDataUrl(value, "audio") }; if (value === null || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "audio_url")) { throw new TypeError("Invalid functions-exec audio argument"); } - return { type: "audio", dataUrl: parseMediaDataUrl(value.audio_url, audioMimes, "audio") }; + return { type: "audio", dataUrl: assertMediaDataUrl(value.audio_url, "audio") }; } /** @@ -128,16 +216,15 @@ export function createWorkerHelpers(options: WorkerHelperOptions): WorkerHelpers }, store(key, value): void { assertStoreKey(key); - assertJsonValue(value); - if (hasDurableMedia(value)) throw new TypeError("functions-exec store rejects media data URLs"); - assertContextEnvelope({ type: "store", key, value }); + const snapshot = snapshotStoreValue(value); + assertContextEnvelope({ type: "store", key, value: snapshot }); if (!values.has(key) && values.size >= MAX_STORE_ENTRIES) throw new RangeError("functions-exec store is full"); - values.set(key, clone(value)); + values.set(key, snapshot); }, load(key): JsonValue | undefined { assertStoreKey(key); const value = values.get(key); - return value === undefined ? undefined : clone(value); + return value === undefined ? undefined : cloneSnapshot(value); }, async yield(): Promise { await options.onYield?.(); diff --git a/packages/core/test/functions-exec/ndjson.test.ts b/packages/core/test/functions-exec/ndjson.test.ts index a215ae62..655048cd 100644 --- a/packages/core/test/functions-exec/ndjson.test.ts +++ b/packages/core/test/functions-exec/ndjson.test.ts @@ -4,10 +4,11 @@ import { encodeNdjsonFrame, type ParentToWorkerFrame, } from "../../src/functions-exec/ndjson"; +import { assertWorkerToParentFrame, type WorkerToParentFrame } from "../../src/functions-exec/protocol"; const frame: ParentToWorkerFrame = { type: "execute", cellId: "cell-1", source: "text('é')" }; -function fatalCode(result: ReturnType): string | undefined { +function fatalCode(result: ReturnType["push"]>): string | undefined { return result.ok ? undefined : result.fatal.code; } @@ -22,6 +23,16 @@ describe("functions-exec NDJSON framing", () => { expect(decoder.finish()).toEqual({ ok: true, frames: [] }); }); + test("admits a bounded private execute source without widening worker output frames", () => { + const source = "x".repeat(2_048); + const execute: ParentToWorkerFrame = { type: "execute", cellId: "cell-1", source }; + const parent = new NdjsonDecoder(); + expect(parent.push(encodeNdjsonFrame(execute))).toEqual({ ok: true, frames: [execute] }); + + const worker = new NdjsonDecoder(assertWorkerToParentFrame); + expect(fatalCode(worker.push(new TextEncoder().encode(`${JSON.stringify({ type: "cell", cellId: "cell-1", frame: { type: "text", text: "x".repeat(513) } })}\n`)))).toBe("line_too_large"); + }); + test("rejects malformed json, malformed UTF-8, oversized lines, and excessive total output", () => { const malformed = new NdjsonDecoder(); expect(fatalCode(malformed.push(new TextEncoder().encode("{not json}\n")))).toBe("invalid_json"); @@ -46,4 +57,28 @@ describe("functions-exec NDJSON framing", () => { const epipe = new NdjsonDecoder(); expect(epipe.finish("epipe")).toEqual({ ok: false, fatal: { code: "broken_pipe", message: "NDJSON pipe closed before completion" } }); }); + + test("requires a completed worker terminal frame even when EOF has no pending bytes", () => { + const decoder = new NdjsonDecoder(assertWorkerToParentFrame); + expect(decoder.push(encodeNdjsonFrame({ type: "ready" }))).toEqual({ ok: true, frames: [{ type: "ready" }] }); + expect(decoder.finish()).toEqual({ ok: false, fatal: { code: "broken_pipe", message: "NDJSON stream ended before completion" } }); + }); + + test("accepts EPIPE only after a completed worker terminal frame", () => { + const completed: WorkerToParentFrame = { type: "completed", cellId: "cell-1" }; + const decoder = new NdjsonDecoder(assertWorkerToParentFrame); + expect(decoder.push(encodeNdjsonFrame(completed))).toEqual({ ok: true, frames: [completed] }); + expect(decoder.finish("epipe")).toEqual({ ok: true, frames: [] }); + }); + + test("does not count CRLF framing bytes against an exact payload limit", () => { + const encoded = encodeNdjsonFrame(frame); + const payload = encoded.slice(0, -1); + const crlf = new Uint8Array(payload.byteLength + 2); + crlf.set(payload); + crlf.set([0x0d, 0x0a], payload.byteLength); + const decoder = new NdjsonDecoder({ maxLineBytes: payload.byteLength }); + + expect(decoder.push(crlf)).toEqual({ ok: true, frames: [frame] }); + }); }); diff --git a/packages/core/test/functions-exec/protocol.test.ts b/packages/core/test/functions-exec/protocol.test.ts index ec20047a..0bfd6a23 100644 --- a/packages/core/test/functions-exec/protocol.test.ts +++ b/packages/core/test/functions-exec/protocol.test.ts @@ -1,18 +1,21 @@ import { describe, expect, test } from "bun:test"; import { MAX_CONTEXT_ENVELOPE_BYTES, - MAX_UNTRUSTED_STRING_CHARS, + MAX_FUNCTIONS_EXEC_SOURCE_BYTES, assertParentToWorkerFrame, assertWorkerToParentFrame, encodedEnvelopeBytes, } from "../../src/functions-exec/protocol"; describe("functions-exec protocol bounds", () => { - test("accepts strict parent and worker envelopes below the single aggregate context cap", () => { + test("accepts a bounded private source transport while keeping worker envelopes under the context cap", () => { const parent = assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "text('ok')" }); const worker = assertWorkerToParentFrame({ type: "cell", cellId: "cell-1", frame: { type: "text", text: "ok" } }); expect(parent).toEqual({ type: "execute", cellId: "cell-1", source: "text('ok')" }); + expect(assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "x".repeat(2_048) })).toEqual({ + type: "execute", cellId: "cell-1", source: "x".repeat(2_048), + }); expect(worker).toEqual({ type: "cell", cellId: "cell-1", frame: { type: "text", text: "ok" } }); expect(encodedEnvelopeBytes(worker)).toBeLessThanOrEqual(MAX_CONTEXT_ENVELOPE_BYTES); }); @@ -21,7 +24,19 @@ describe("functions-exec protocol bounds", () => { expect(() => assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "ok", extra: true })).toThrow(/unknown/i); expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { value: undefined } })).toThrow(/JSON/i); expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { a: { b: { c: { d: { e: "too deep" } } } } } })).toThrow(/depth/i); - expect(() => assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "x".repeat(MAX_UNTRUSTED_STRING_CHARS + 1) })).toThrow(/string/i); + expect(() => assertParentToWorkerFrame({ type: "execute", cellId: "cell-1", source: "x".repeat(MAX_FUNCTIONS_EXEC_SOURCE_BYTES + 1) })).toThrow(/source/i); expect(() => assertWorkerToParentFrame({ type: "call", callId: "call-1", cellId: "cell-1", name: "bash", args: { first: "x".repeat(250), second: "y".repeat(250) } })).toThrow(/envelope/i); }); + + test("applies the helper media MIME, base64, and detail rules to raw worker frames", () => { + expect(() => assertWorkerToParentFrame({ + type: "cell", cellId: "cell-1", frame: { type: "image", dataUrl: "data:text/plain;base64,eA==", detail: "auto" }, + })).toThrow(/image/i); + expect(() => assertWorkerToParentFrame({ + type: "cell", cellId: "cell-1", frame: { type: "audio", dataUrl: "data:audio/mpeg;base64,%%%%" }, + })).toThrow(/audio/i); + expect(() => assertWorkerToParentFrame({ + type: "cell", cellId: "cell-1", frame: { type: "image", dataUrl: "data:image/png;base64,aGVsbG8=", detail: "sharp" }, + })).toThrow(/detail/i); + }); }); diff --git a/packages/core/test/functions-exec/retention.test.ts b/packages/core/test/functions-exec/retention.test.ts index 7ac4c198..68746383 100644 --- a/packages/core/test/functions-exec/retention.test.ts +++ b/packages/core/test/functions-exec/retention.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { COMPLETED_CELL_FRAME_RETENTION, + MAX_PENDING_NOTIFICATIONS, TRANSIENT_MEDIA_RETENTION, BoundedNotifications, CellQuota, @@ -14,6 +15,12 @@ describe("functions-exec finite retention", () => { expect(retainTail(["old"], "new", 0)).toEqual([]); }); + test("retains exactly the requested tail for every integer capacity", () => { + expect(retainTail(["one", "two"], "three", 1)).toEqual(["three"]); + expect(retainTail(["one", "two"], "three", 2)).toEqual(["two", "three"]); + expect(retainTail(["one", "two"], "three", 3)).toEqual(["one", "two", "three"]); + }); + test("delivers exactly its notification capacity with no off-by-one", () => { const notifications = new BoundedNotifications(2); expect(notifications.push("first")).toBe(true); @@ -32,4 +39,16 @@ describe("functions-exec finite retention", () => { expect(quota.tryConsumeNotification("second")).toBe(false); expect(quota.tryConsumeText("x".repeat(160))).toBe(false); }); + + test("caller quotas can tighten but never expand global text or notification maxima", () => { + const text = new CellQuota({ maxBytes: 4_096, maxNotifications: 4_096 }); + expect(text.tryConsumeText("x".repeat(250))).toBe(true); + expect(text.tryConsumeText("y".repeat(250))).toBe(false); + + const notifications = new CellQuota({ maxBytes: 4_096, maxNotifications: 4_096 }); + for (let index = 0; index < MAX_PENDING_NOTIFICATIONS; index += 1) { + expect(notifications.tryConsumeNotification(`note-${index}`)).toBe(true); + } + expect(notifications.tryConsumeNotification("one too many")).toBe(false); + }); }); diff --git a/packages/core/test/functions-exec/worker-api.test.ts b/packages/core/test/functions-exec/worker-api.test.ts index bb769997..a99eedfd 100644 --- a/packages/core/test/functions-exec/worker-api.test.ts +++ b/packages/core/test/functions-exec/worker-api.test.ts @@ -29,9 +29,53 @@ describe("functions-exec worker helper API", () => { expect(() => helpers.image("data:text/plain;base64,eA==")).toThrow(/image/i); expect(() => helpers.audio("data:audio/mpeg;base64,%%%%")).toThrow(/base64/i); expect(() => helpers.store("saved", { attachment: "data:image/png;base64,aGVsbG8=" })).toThrow(/media/i); + expect(() => helpers.store("saved", { attachment: "data%3Aimage%2Fpng%3Bbase64%2CaGVsbG8%3D" })).toThrow(/media/i); + expect(() => helpers.store("saved", { attachment: "d\\\\u0061ta:image/png;base64,aGVsbG8=" })).toThrow(/media/i); + expect(() => helpers.store("saved", { note: "attachment=data:image/png;base64,aGVsbG8=" })).toThrow(/media/i); + expect(() => helpers.store("data:image/png;base64,aGVsbG8=", "ok")).toThrow(/media/i); expect(() => helpers.store("x".repeat(65), "ok")).toThrow(/key/i); }); + test("takes a canonical snapshot without invoking hostile getters, toJSON, or prototype hooks", () => { + const helpers = createWorkerHelpers({ emit: () => {} }); + let getterCalls = 0; + let toJsonCalls = 0; + let prototypeCalls = 0; + const accessor = {}; + Object.defineProperty(accessor, "attachment", { + enumerable: true, + get() { + getterCalls += 1; + return "data:image/png;base64,aGVsbG8="; + }, + }); + const toJson = { + toJSON() { + toJsonCalls += 1; + return "data:image/png;base64,aGVsbG8="; + }, + }; + const proxied = new Proxy({ answer: "42" }, { + get() { + getterCalls += 1; + throw new Error("must not read through a proxy getter"); + }, + getPrototypeOf() { + prototypeCalls += 1; + throw new Error("must not read a prototype"); + }, + }); + + expect(() => helpers.store("accessor", accessor as never)).toThrow(); + expect(() => helpers.store("to-json", toJson as never)).toThrow(/JSON/i); + helpers.store("proxied", proxied as never); + + expect(getterCalls).toBe(0); + expect(toJsonCalls).toBe(0); + expect(prototypeCalls).toBe(0); + expect(helpers.load("proxied")).toEqual({ answer: "42" }); + }); + test("stores only bounded JSON values and returns an immutable copy", () => { const helpers = createWorkerHelpers({ emit: () => {} }); helpers.store("result", { answer: "42" });