diff --git a/packages/core/src/functions-exec/ndjson.ts b/packages/core/src/functions-exec/ndjson.ts new file mode 100644 index 00000000..65771242 --- /dev/null +++ b/packages/core/src/functions-exec/ndjson.ts @@ -0,0 +1,180 @@ +import { + MAX_CONTEXT_ENVELOPE_BYTES, + MAX_EXECUTE_ENVELOPE_BYTES, + assertParentToWorkerFrame, + assertWorkerToParentFrame, + assertWireFrame, + type FunctionsExecFatalCode, + type ParentToWorkerFrame, + type WorkerToParentFrame, +} from "./protocol"; + +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; + 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`); + 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; +} + +/** 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 completed = false; + private readonly requiresCompletion: boolean; + 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; + 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"); + + 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; + 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; + 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" && (!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; + 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); + 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 }; + } +} + +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 new file mode 100644 index 00000000..4d8f3dae --- /dev/null +++ b/packages/core/src/functions-exec/protocol.ts @@ -0,0 +1,281 @@ +/** + * 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; +// 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 }; + +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 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", + "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`); + } +} + +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)) { + 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; +} + +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) { + 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"]); + assertMediaDataUrl(value.dataUrl, "image"); + assertImageDetail(value.detail); + return assertContextEnvelope(value as CellFrame); + } + case "audio": { + assertExactKeys(value, ["type", "dataUrl"]); + assertMediaDataUrl(value.dataUrl, "audio"); + 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"); + assertFunctionsExecSource(value.source); + return assertExecuteEnvelope(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..c146d922 --- /dev/null +++ b/packages/core/src/functions-exec/retention.ts @@ -0,0 +1,100 @@ +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 []; + const retainedExisting = maxItems - 1; + return [...items.slice(items.length - retainedExisting), 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 = 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 { + 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..c1e3ce4b --- /dev/null +++ b/packages/core/src/functions-exec/worker-api.ts @@ -0,0 +1,234 @@ +import { + ImageDetail, + MAX_CONTEXT_ENVELOPE_BYTES, + MAX_JSON_CONTAINER_ENTRIES, + MAX_JSON_DEPTH, + MAX_MEDIA_DATA_URL_BYTES, + assertCellFrame, + assertContextEnvelope, + assertImageDetail, + assertMediaDataUrl, + assertUntrustedString, + type CellFrame, + type JsonValue, +} from "./protocol"; +import { CellQuota } from "./retention"; + +export { ImageDetail, MAX_MEDIA_DATA_URL_BYTES } from "./protocol"; + +export const MAX_STORE_KEY_CHARS = 64; +export const MAX_STORE_ENTRIES = 16; + +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: unknown): void; + load(key: string): JsonValue | undefined; + yield(): Promise; +} + +export interface WorkerHelperOptions { + emit(frame: CellFrame): void; + onYield?(): void | Promise; + quota?: CellQuota; +} + +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 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 descriptorValue(descriptor: PropertyDescriptor, label: string): unknown { + if (!own.call(descriptor, "value")) throw new TypeError(`functions-exec store rejects accessor ${label}`); + return descriptor.value; +} + +function immutable(value: JsonValue): JsonValue { + if (value !== null && typeof value === "object") Object.freeze(value); + return value; +} + +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; + } + 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; + assertImageDetail(value); + 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: 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: assertMediaDataUrl(value.image_url, "image"), detail: imageDetail(value.detail) }; +} + +function normalizeAudio(value: string | AudioInput): CellFrame { + 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: assertMediaDataUrl(value.audio_url, "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); + 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, snapshot); + }, + load(key): JsonValue | undefined { + assertStoreKey(key); + const value = values.get(key); + return value === undefined ? undefined : cloneSnapshot(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..655048cd --- /dev/null +++ b/packages/core/test/functions-exec/ndjson.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + NdjsonDecoder, + 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["push"]>): 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("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"); + + 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" } }); + }); + + 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 new file mode 100644 index 00000000..0bfd6a23 --- /dev/null +++ b/packages/core/test/functions-exec/protocol.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_CONTEXT_ENVELOPE_BYTES, + MAX_FUNCTIONS_EXEC_SOURCE_BYTES, + assertParentToWorkerFrame, + assertWorkerToParentFrame, + encodedEnvelopeBytes, +} from "../../src/functions-exec/protocol"; + +describe("functions-exec protocol bounds", () => { + 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); + }); + + 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_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 new file mode 100644 index 00000000..68746383 --- /dev/null +++ b/packages/core/test/functions-exec/retention.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { + COMPLETED_CELL_FRAME_RETENTION, + MAX_PENDING_NOTIFICATIONS, + 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("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); + 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); + }); + + 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 new file mode 100644 index 00000000..a99eedfd --- /dev/null +++ b/packages/core/test/functions-exec/worker-api.test.ts @@ -0,0 +1,88 @@ +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("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" }); + const loaded = helpers.load("result") as { answer: string }; + loaded.answer = "mutated"; + + expect(helpers.load("result")).toEqual({ answer: "42" }); + expect(helpers.load("missing")).toBeUndefined(); + }); +});