diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 8e5d3fc2..d6db10ee 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -58,6 +58,17 @@ export { WorkspaceServiceProxy, type WorkspaceServiceProxyProps, } from "./proxy.js"; +export { + capability, + fetchCapability, + workspaceFs, + type CapabilityMeta, + type MethodKeys, + type ReplCapability, + type ReplFetchInit, + type ReplFetchResponse, + type WorkspaceFsOptions, +} from "./repl/capability.js"; export { ReplSession, type ReplEvalOptions, diff --git a/packages/computer/src/repl/bridge.ts b/packages/computer/src/repl/bridge.ts new file mode 100644 index 00000000..97efad8a --- /dev/null +++ b/packages/computer/src/repl/bridge.ts @@ -0,0 +1,284 @@ +// Host-side capability bridge for REPL sessions. +// +// One bridge per session host. Root grants are swapped wholesale by +// attach(); the registry of handles the session has acquired from them +// (tabs, response objects, …) lives as long as the session host does. +// Every handle remembers which root granted it, so revoking a root also +// cuts every handle derived from it. The isolate never holds a real +// target — it calls `invoke(id, path, args, recipe)` across native +// Workers RPC, and the bridge resolves, calls, and classifies the outcome: +// +// - plain data → encoded value (recorded, replayable) +// - object-with-methods / function → minted handle (id + shape + recipe) +// - thrown error → structured error reply (recorded, so a cell that +// caught it replays identically) +// +// `invoke` never throws: a thrown error would cross two RPC hops and lose +// its structure; a reply object survives verbatim. +// +// Handle ids are namespaced by a per-host epoch, so a handle recorded +// before a restart can never collide with a live one — a lookup miss is +// answered with the handle's acquisition recipe (stale lease), and a root +// miss with the attachment's actual grant list (not granted). + +import { RpcTarget } from "cloudflare:workers"; + +import type { ReplCapability, ReplCapabilityShape } from "./capability.js"; +import { describeCapabilityTarget, hasCallableSurface, isReplCapability } from "./capability.js"; +import { decodeReplValue, encodeReplValue } from "./codec.js"; + +export interface ReplInvokeError { + name: string; + message: string; + /** Protocol-level classification (stale-lease, not-granted, …). */ + kind?: string; +} + +export interface ReplInvokeHandle { + id: string; + shape: ReplCapabilityShape; + recipe: string; +} + +export type ReplInvokeReply = + | { ok: true; value: unknown } + | { ok: true; handle: ReplInvokeHandle } + | { ok: false; error: ReplInvokeError }; + +export interface ReplBridgeOptions { + /** Per-effect recorded-value ceiling in JSON bytes (default 1 MiB). */ + maxEffectBytes?: number; +} + +const DEFAULT_MAX_EFFECT_BYTES = 1024 * 1024; + +/** Shorten an encoded-args array into a human recipe fragment. */ +function argsPreview(args: unknown[]): string { + const parts = args.map((arg) => { + const text = JSON.stringify(arg) ?? "undefined"; + return text.length > 60 ? `${text.slice(0, 57)}…` : text; + }); + const joined = parts.join(", "); + return joined.length > 120 ? `${joined.slice(0, 117)}…` : joined; +} + +interface RegistryEntry { + value: unknown; + recipe: string; + /** The root grant name this object descends from (itself, for roots). */ + root: string; +} + +export class ReplCapabilityBridge extends RpcTarget { + // Roots are replaced wholesale on attach(); handles persist for the + // bridge's (= session host's) lifetime. + #roots = new Map(); + #handles = new Map(); + #shapes: Record = {}; + #grantNames: string[] = []; + #epoch: string; + #next = 1; + #maxEffectBytes: number; + + constructor(capabilities: Record, options: ReplBridgeOptions = {}) { + super(); + this.#epoch = Math.random().toString(36).slice(2, 8); + this.#maxEffectBytes = options.maxEffectBytes ?? DEFAULT_MAX_EFFECT_BYTES; + this.attach(capabilities); + } + + /** Replace the root grants. Handles survive — but only stay callable + * while the root they descend from remains granted. */ + attach(capabilities: Record): void { + this.#roots.clear(); + this.#shapes = {}; + this.#grantNames = []; + for (const [name, grant] of Object.entries(capabilities)) { + if (!isReplCapability(grant)) { + throw new TypeError( + `Capability ${JSON.stringify(name)} must be created with capability(), ` + + "fetchCapability(), or workspaceFs() — got a raw value.", + ); + } + // Shape reflection throws on unrecordable grant data — deliberately, + // at attach time, where the host developer can see it. + const shape = describeCapabilityTarget(grant.target); + if (grant.meta.description !== undefined) shape.description = grant.meta.description; + if (grant.meta.docs !== undefined) shape.docs = { ...grant.meta.docs } as Record; + this.#roots.set(name, { value: grant.target, recipe: name, root: name }); + this.#shapes[name] = shape; + this.#grantNames.push(name); + } + } + + /** Current attachment's grant shapes (host-side; used for cell snapshots). */ + shapes(): Record { + return this.#shapes; + } + + // Resolve an id to a live, currently-granted object — or the structured + // error that says exactly why not and what to do about it. + #resolve( + id: string, + recipe: string, + ): { ok: true; entry: RegistryEntry } | { ok: false; error: ReplInvokeError } { + const entry = this.#roots.get(id) ?? this.#handles.get(id); + if (!entry) { + if (id.startsWith("~")) { + return { + ok: false, + error: { + name: "StaleLeaseError", + kind: "stale-lease", + message: + "This handle is stale: the live object behind it died with a " + + "session host restart. Committed cells still replay from the log. " + + `To use it in new code, re-acquire it by re-running: ${recipe}`, + }, + }; + } + return { + ok: false, + error: { + name: "NotGrantedError", + kind: "not-granted", + message: + `"${id}" is not granted in this attachment. Granted here: ` + + `${this.#grantNames.join(", ") || "(nothing)"}. Grants are attach-time — ` + + "the host must re-attach the session with this capability before new " + + "code can call it. (Committed cells replay from the log and are unaffected.)", + }, + }; + } + if (!this.#roots.has(entry.root)) { + return { + ok: false, + error: { + name: "NotGrantedError", + kind: "not-granted", + message: + `This handle descends from "${entry.root}" (via ${entry.recipe}), ` + + `which is not granted in this attachment. Granted here: ` + + `${this.#grantNames.join(", ") || "(nothing)"}. Re-attach with ` + + `"${entry.root}" to use it again.`, + }, + }; + } + return { ok: true, entry }; + } + + async invoke(id: string, path: string, args: unknown[], recipe: string): Promise { + const resolved = this.#resolve(id, recipe); + if (!resolved.ok) return { ok: false, error: resolved.error }; + const entry = resolved.entry; + + // Revive handle markers in the args into live registry objects. + let argError: ReplInvokeError | undefined; + let decodedArgs: unknown[]; + try { + decodedArgs = decodeReplValue(args, (tagged) => { + if (tagged.$repl !== "handle") return undefined; + const argRecipe = typeof tagged.recipe === "string" ? tagged.recipe : String(tagged.id); + const argResolved = this.#resolve(String(tagged.id), argRecipe); + if (!argResolved.ok) { + argError = argResolved.error; + throw new Error("unresolvable handle argument"); + } + return argResolved.entry.value; + }) as unknown[]; + } catch (error) { + if (argError !== undefined) return { ok: false, error: argError }; + return { + ok: false, + error: { + name: "TypeError", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + + // Walk the path from the target and call. + let parent: unknown = undefined; + let fn: unknown = entry.value; + if (path !== "") { + for (const segment of path.split(".")) { + parent = fn; + fn = (fn as Record | undefined)?.[segment]; + } + } + if (typeof fn !== "function") { + return { + ok: false, + error: { + name: "TypeError", + message: + `${entry.recipe}${path === "" ? "" : `.${path}`} is not a function ` + + "on this capability.", + }, + }; + } + + let result: unknown; + try { + result = await Reflect.apply(fn, parent, decodedArgs); + } catch (error) { + // A real capability error. Name and message only — host stack traces + // don't belong inside the session. + const name = error instanceof Error ? error.name : "Error"; + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: { name, message } }; + } + + // Callable results become handles the session can keep using. + if (hasCallableSurface(result)) { + const handleId = `~${this.#epoch}-${this.#next++}`; + const call = `${entry.recipe}${path === "" ? "" : `.${path}`}(${argsPreview(args)})`; + let shape: ReplCapabilityShape; + try { + shape = describeCapabilityTarget(result); + } catch (error) { + return { + ok: false, + error: { + name: "UnrecordableValueError", + message: + `The object returned by ${call} cannot be granted as a handle: ` + + (error instanceof Error ? error.message : String(error)), + }, + }; + } + this.#handles.set(handleId, { value: result, recipe: call, root: entry.root }); + return { ok: true, handle: { id: handleId, shape, recipe: call } }; + } + + // Plain data: encode for the log, enforce the size ceiling. + let encoded: unknown; + try { + encoded = encodeReplValue(result); + } catch (error) { + return { + ok: false, + error: { + name: "UnrecordableValueError", + message: error instanceof Error ? error.message : String(error), + }, + }; + } + const bytes = JSON.stringify(encoded)?.length ?? 0; + if (bytes > this.#maxEffectBytes) { + return { + ok: false, + error: { + name: "OversizedResultError", + kind: "oversized-result", + message: + `This capability call returned ${bytes} bytes; the per-call recorded ` + + `ceiling is ${this.#maxEffectBytes}. Results are recorded in the session ` + + "log verbatim (never truncated). Write large data to workspace files " + + "and return a path, or return a smaller slice.", + }, + }; + } + return { ok: true, value: encoded }; + } +} diff --git a/packages/computer/src/repl/capability.test.ts b/packages/computer/src/repl/capability.test.ts new file mode 100644 index 00000000..21e0f833 --- /dev/null +++ b/packages/computer/src/repl/capability.test.ts @@ -0,0 +1,238 @@ +// Unit tests for the pure host-side capability logic: wrapping/branding, +// target-shape reflection, callable-surface classification, and the fetch +// capability's allowlist + response mapping. Everything here runs in plain +// Node — end-to-end capability behavior (bridge, recording, replay) is +// covered by the workerd integration suite in tests/repl-capabilities.test.ts. + +import { describe, expect, it } from "vitest"; + +import { + capability, + describeCapabilityTarget, + fetchCapability, + hasCallableSurface, + isReplCapability, + type ReplFetchResponse, +} from "./capability.js"; + +describe("capability()", () => { + it("wraps objects and functions, carrying meta", () => { + const target = { ping: () => "pong" }; + const wrapped = capability(target, { description: "pinger", docs: { ping: "ping() → pong" } }); + expect(wrapped.target).toBe(target); + expect(wrapped.meta.description).toBe("pinger"); + expect(wrapped.meta.docs?.ping).toBe("ping() → pong"); + expect(Object.isFrozen(wrapped)).toBe(true); + expect(capability(() => 1).meta).toEqual({}); + }); + + it("rejects null and primitive targets", () => { + expect(() => capability(null as never)).toThrow(TypeError); + expect(() => capability(42 as never)).toThrow("needs an object or function"); + expect(() => capability("hi" as never)).toThrow(TypeError); + }); + + it("isReplCapability() accepts only branded wrappers", () => { + expect(isReplCapability(capability({ m: () => 1 }))).toBe(true); + expect(isReplCapability({ target: {}, meta: {} })).toBe(false); + expect(isReplCapability(null)).toBe(false); + expect(isReplCapability("capability")).toBe(false); + }); +}); + +describe("describeCapabilityTarget()", () => { + it("splits methods from data and encodes data values", () => { + const shape = describeCapabilityTarget({ + get: (id: string) => id, + region: "eu-west", + updated: new Date(1700000000000), + }); + expect(shape.methods).toEqual(["get"]); + expect(shape.data["region"]).toBe("eu-west"); + expect(shape.data["updated"]).toEqual({ $repl: "date", v: 1700000000000 }); + expect(shape.callable).toBeUndefined(); + expect(shape.opaque).toBeUndefined(); + }); + + it("nests objects with callable surface as children, keeps plain data flat", () => { + const shape = describeCapabilityTarget({ + users: { list: () => [], role: "admin" }, + limits: { rps: 10 }, + }); + expect(Object.keys(shape.children)).toEqual(["users"]); + expect(shape.children["users"]?.methods).toEqual(["list"]); + expect(shape.children["users"]?.data["role"]).toBe("admin"); + expect(shape.data["limits"]).toEqual({ rps: 10 }); + }); + + it("finds class methods across the prototype chain, once", () => { + class Base { + close() {} + } + class Tab extends Base { + url = "about:blank"; + read() {} + click() {} + } + const shape = describeCapabilityTarget(new Tab()); + expect(shape.methods.sort()).toEqual(["click", "close", "read"]); + expect(shape.data["url"]).toBe("about:blank"); + expect(shape.opaque).toBeUndefined(); + }); + + it("marks bare functions callable, including attached helper methods", () => { + const send = (to: string) => to; + send.preview = () => "preview"; + const shape = describeCapabilityTarget(send); + expect(shape.callable).toBe(true); + expect(shape.methods).toEqual(["preview"]); + }); + + it("rejects callable surfaces nested beyond the depth ceiling", () => { + const deep = { a: { b: { c: { d: { m: () => 1 } } } } }; + expect(() => describeCapabilityTarget(deep)).toThrow("Flatten the target"); + }); + + it("rejects grant data the log cannot record, at reflection time", () => { + expect(() => describeCapabilityTarget({ token: Symbol("secret") })).toThrow( + "cannot be recorded", + ); + }); + + it("marks unreflectable non-plain objects opaque (RPC stubs)", () => { + // Nothing enumerable, nothing on a walkable prototype — the shape of a + // service-binding / DO stub whose properties materialize on access. + const stub = new Proxy( + {}, + { get: () => () => "materialized", getPrototypeOf: () => null }, + ); + const shape = describeCapabilityTarget(stub); + expect(shape.opaque).toBe(true); + expect(shape.methods).toEqual([]); + // A plain empty object is NOT opaque — it's just an empty grant. + expect(describeCapabilityTarget({}).opaque).toBeUndefined(); + }); +}); + +describe("hasCallableSurface()", () => { + it("classifies functions, methodful objects, and class instances as callable", () => { + expect(hasCallableSurface(() => 1)).toBe(true); + expect(hasCallableSurface({ m: () => 1 })).toBe(true); + expect(hasCallableSurface({ nested: { deep: { m: () => 1 } } })).toBe(true); + expect(hasCallableSurface(new (class X {})())).toBe(true); + expect(hasCallableSurface([() => 1])).toBe(true); + }); + + it("classifies plain data and encodable exotics as data", () => { + expect(hasCallableSurface({ a: 1, b: [2, 3] })).toBe(false); + expect(hasCallableSurface(null)).toBe(false); + expect(hasCallableSurface(42)).toBe(false); + expect(hasCallableSurface(new Date())).toBe(false); + expect(hasCallableSurface(new Map([[1, 2]]))).toBe(false); + expect(hasCallableSurface(new Set([1]))).toBe(false); + expect(hasCallableSurface(new Uint8Array(4))).toBe(false); + expect(hasCallableSurface(new ArrayBuffer(4))).toBe(false); + expect(hasCallableSurface([1, "two"])).toBe(false); + }); +}); + +type FetchTarget = (url: string, init?: never) => Promise; + +describe("fetchCapability()", () => { + it("generates a description matching each egress mode", () => { + expect(fetchCapability().meta.description).toBe( + "HTTP fetch with the host worker's network access", + ); + expect(fetchCapability({ fetch: async () => new Response() }).meta.description).toBe( + "HTTP fetch routed through a host-provided gateway", + ); + expect(fetchCapability({ allow: ["api.example.com"] }).meta.description).toBe( + "HTTP fetch restricted to: api.example.com", + ); + expect(fetchCapability({ allow: [] }).meta.description).toBe( + "HTTP fetch restricted to: (no hosts)", + ); + }); + + it("routes through a provided fetcher and maps init + response", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetcher = { + fetch: async (url: string, init?: RequestInit) => { + calls.push({ url, init: init ?? {} }); + return new Response('{"ok":true}', { + status: 201, + statusText: "Created", + headers: { "content-type": "application/json", "x-request-id": "r1" }, + }); + }, + }; + const doFetch = fetchCapability(fetcher).target as FetchTarget; + const res = await doFetch("https://internal.test/orders", { + method: "POST", + headers: { authorization: "Bearer t" }, + body: '{"sku":1}', + } as never); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://internal.test/orders"); + expect(calls[0]?.init.method).toBe("POST"); + expect(calls[0]?.init.body).toBe('{"sku":1}'); + expect(res.status).toBe(201); + expect(res.ok).toBe(true); + expect(res.statusText).toBe("Created"); + expect(res.headers["x-request-id"]).toBe("r1"); + // Body is captured once; text()/json() are repeatable. + expect(res.text()).toBe('{"ok":true}'); + expect(res.text()).toBe('{"ok":true}'); + expect(res.json()).toEqual({ ok: true }); + expect(res.json()).toEqual({ ok: true }); + }); + + it("omits init fields that were not provided", async () => { + let seen: RequestInit | undefined; + const fetcher = { + fetch: async (_url: string, init?: RequestInit) => { + seen = init; + return new Response("ok"); + }, + }; + const doFetch = fetchCapability(fetcher).target as FetchTarget; + await doFetch("https://internal.test/"); + expect(seen).toEqual({}); + }); + + it("json() surfaces a parse error for non-JSON bodies", async () => { + const fetcher = { fetch: async () => new Response("") }; + const doFetch = fetchCapability(fetcher).target as FetchTarget; + const res = await doFetch("https://internal.test/"); + expect(() => res.json()).toThrow(SyntaxError); + }); + + it("enforces the allowlist by exact hostname, before any network call", async () => { + const realFetch = globalThis.fetch; + const hit: string[] = []; + globalThis.fetch = (async (url: RequestInfo | URL) => { + hit.push(String(url)); + return new Response("reached"); + }) as typeof fetch; + try { + const doFetch = fetchCapability({ allow: ["example.com"] }).target as FetchTarget; + + await expect(doFetch("https://evil.test/steal")).rejects.toMatchObject({ + name: "EgressDeniedError", + message: expect.stringContaining('"evil.test"') as string, + }); + // Exact match: subdomains of an allowed host are still denied. + await expect(doFetch("https://api.example.com/")).rejects.toMatchObject({ + name: "EgressDeniedError", + }); + expect(hit).toHaveLength(0); // denied before touching the network + + const res = await doFetch("https://example.com/data"); + expect(res.text()).toBe("reached"); + expect(hit).toEqual(["https://example.com/data"]); + } finally { + globalThis.fetch = realFetch; + } + }); +}); diff --git a/packages/computer/src/repl/capability.ts b/packages/computer/src/repl/capability.ts new file mode 100644 index 00000000..d4766efe --- /dev/null +++ b/packages/computer/src/repl/capability.ts @@ -0,0 +1,327 @@ +// Capability grants for REPL sessions. +// +// One injection idiom: `capability(target, meta?)` wraps any object, class +// instance, RPC stub, or bare function; the session sees it as a global +// named by its grant key. Method calls cross the recorder bridge and are +// logged as effects; plain data on the target is snapshotted per cell so +// replay sees the values a cell originally ran with. `fetchCapability()` +// and `workspaceFs()` are ordinary capabilities built on the same wrapper — +// nothing about them is special-cased downstream. + +import { WorkspaceRuntimeCapability } from "../runtime/capability.js"; +import type { WorkspaceRuntimeAccess, WorkspaceRuntimeFilesystem } from "../runtime/types.js"; +import { encodeReplValue } from "./codec.js"; + +const CAPABILITY_BRAND = Symbol.for("cloudflare.computer.replCapability"); + +/** Method-name keys of T — the only keys `docs` may use. */ +export type MethodKeys = { + [K in keyof T]: T[K] extends (...args: never[]) => unknown ? K : never; +}[keyof T] & + string; + +export interface CapabilityMeta { + /** One-line summary, surfaced to the model in tool text and help(). */ + description?: string; + /** Per-method docs; keys must name real methods of the target. */ + docs?: Partial, string>>; +} + +/** A granted capability: the live target plus its model-facing metadata. */ +export interface ReplCapability { + readonly target: T; + readonly meta: CapabilityMeta; +} + +/** + * Wrap a target for granting to a REPL session. + * + * The target can be a plain object, a class instance, a Durable Object or + * service binding stub, or a bare function (granted as a callable). Only + * what the session actually calls is recorded — wrapping is free. + */ +export function capability( + target: T, + meta: CapabilityMeta = {}, +): ReplCapability { + if (target === null || (typeof target !== "object" && typeof target !== "function")) { + throw new TypeError("capability() needs an object or function target."); + } + return Object.freeze({ + [CAPABILITY_BRAND]: true, + target, + meta, + }) as ReplCapability; +} + +export function isReplCapability(value: unknown): value is ReplCapability { + return ( + typeof value === "object" && + value !== null && + (value as Record)[CAPABILITY_BRAND] === true + ); +} + +// --------------------------------------------------------------------------- +// Target shapes +// +// The isolate never holds real targets — it builds proxies from a shape: +// which keys are methods, which are data (encoded values, snapshotted per +// cell), and which are nested objects with their own surface. Stubs whose +// surface can't be reflected (RPC proxies) are `opaque`: every property +// access is treated as a method. + +export interface ReplCapabilityShape { + /** The target itself is invocable (bare-function grants, returned functions). */ + callable?: true; + /** Unreflectable surface (RPC stub): every property is assumed callable. */ + opaque?: true; + methods: string[]; + /** Encoded plain-data properties (the per-cell snapshot surface). */ + data: Record; + /** Nested objects that themselves have callable surface. */ + children: Record; + description?: string; + docs?: Record; +} + +const MAX_SHAPE_DEPTH = 3; + +/** + * Reflect a target into its shape. Throws when grant data can't be + * recorded (the grant would break replay, so it must fail at attach). + */ +export function describeCapabilityTarget(target: unknown, depth = 0): ReplCapabilityShape { + const shape: ReplCapabilityShape = { methods: [], data: {}, children: {} }; + if (typeof target === "function") shape.callable = true; + if (target === null || (typeof target !== "object" && typeof target !== "function")) { + throw new TypeError("Capability targets must be objects or functions."); + } + + const record = target as Record; + for (const key of Object.keys(record)) { + const value = record[key]; + if (typeof value === "function") { + shape.methods.push(key); + } else if (hasCallableSurface(value)) { + if (depth >= MAX_SHAPE_DEPTH) { + throw new Error( + `Capability data nests callable objects deeper than ${MAX_SHAPE_DEPTH} ` + + `levels (at ${JSON.stringify(key)}). Flatten the target.`, + ); + } + shape.children[key] = describeCapabilityTarget(value, depth + 1); + } else { + shape.data[key] = encodeReplValue(value); + } + } + + // Class instances keep their methods on the prototype chain. + let prototype = Object.getPrototypeOf(target) as object | null; + while ( + prototype !== null && + prototype !== Object.prototype && + prototype !== Function.prototype + ) { + for (const key of Object.getOwnPropertyNames(prototype)) { + if (key === "constructor" || shape.methods.includes(key)) continue; + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (typeof descriptor?.value === "function") shape.methods.push(key); + } + prototype = Object.getPrototypeOf(prototype); + } + + // Nothing reflectable at all on a non-plain object: an RPC stub (DO + // namespace stubs, service bindings) whose properties materialize on + // access. Treat every property as a method and let the live call decide. + if ( + !shape.callable && + shape.methods.length === 0 && + Object.keys(shape.data).length === 0 && + Object.keys(shape.children).length === 0 && + Object.getPrototypeOf(target) !== Object.prototype + ) { + shape.opaque = true; + } + + return shape; +} + +/** True when a value belongs on the callable surface rather than in data. */ +export function hasCallableSurface(value: unknown): boolean { + if (typeof value === "function") return true; + if (value === null || typeof value !== "object") return false; + // Encodable exotics are data, never callables. + if ( + value instanceof Date || + value instanceof Map || + value instanceof Set || + value instanceof Uint8Array || + value instanceof ArrayBuffer + ) { + return false; + } + if (Array.isArray(value)) return value.some((item) => hasCallableSurface(item)); + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype !== Object.prototype && prototype !== null) return true; + return Object.values(value).some((item) => hasCallableSurface(item)); +} + +// --------------------------------------------------------------------------- +// fetchCapability — the one fetch factory. Outbound network access exists +// only when this grant is present; its absence is the deny-by-default. + +export interface ReplFetchInit { + method?: string; + headers?: Record; + body?: string | Uint8Array; +} + +export interface ReplFetchResponse { + status: number; + ok: boolean; + statusText: string; + url: string; + headers: Record; + /** Response body as text (captured once; callable any number of times). */ + text(): string; + /** Response body parsed as JSON. */ + json(): unknown; +} + +type FetcherLike = { fetch(input: string, init?: RequestInit): Promise }; + +/** + * Build a fetch capability. + * + * - `fetchCapability()` — inherit the host worker's network access. + * - `fetchCapability(fetcher)` — route through a gateway / service binding. + * - `fetchCapability({ allow: [...] })` — hostname allowlist (exact match). + * + * The description is generated from the configuration, so the model's docs + * can never drift from the actual egress policy. + */ +export function fetchCapability( + config?: FetcherLike | { allow: readonly string[] }, +): ReplCapability<(url: string, init?: ReplFetchInit) => Promise> { + // A fetcher always has a callable `fetch`; an allowlist config never + // does. Probe the function first — it's the unambiguous signal. + const fetcher = + config && typeof (config as FetcherLike).fetch === "function" + ? (config as FetcherLike) + : undefined; + const allow = + !fetcher && config && Array.isArray((config as { allow: unknown }).allow) + ? [...(config as { allow: readonly string[] }).allow] + : undefined; + + const doFetch = async (url: string, init?: ReplFetchInit): Promise => { + if (allow !== undefined) { + const host = new URL(url).hostname; + if (!allow.includes(host)) { + const error = new Error( + `fetch to ${JSON.stringify(host)} is not allowed by this capability ` + + `(allowed hosts: ${allow.join(", ") || "(none)"}). Ask your harness ` + + "to widen the allowlist if this host is needed.", + ); + error.name = "EgressDeniedError"; + throw error; + } + } + const transport = fetcher ?? (globalThis as unknown as FetcherLike); + const requestInit: RequestInit = {}; + if (init?.method !== undefined) requestInit.method = init.method; + if (init?.headers !== undefined) requestInit.headers = init.headers; + if (init?.body !== undefined) requestInit.body = init.body as BodyInit; + const response = await transport.fetch(url, requestInit); + // Capture the body once — the returned handle's text()/json() replay + // from this capture, so they are repeatable and deterministic. + const bodyText = await response.text(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { + status: response.status, + ok: response.ok, + statusText: response.statusText, + url: response.url, + headers, + text: () => bodyText, + json: () => JSON.parse(bodyText) as unknown, + }; + }; + + const description = + allow !== undefined + ? `HTTP fetch restricted to: ${allow.join(", ") || "(no hosts)"}` + : fetcher + ? "HTTP fetch routed through a host-provided gateway" + : "HTTP fetch with the host worker's network access"; + + return capability(doFetch, { description }); +} + +// --------------------------------------------------------------------------- +// workspaceFs — the workspace filesystem as an ordinary capability. Reads +// and writes are recorded effects: replay serves recorded results and never +// re-fires writes. + +export interface WorkspaceFsOptions { + /** Confine the capability under this absolute root (default "/"). */ + root?: string; + access?: WorkspaceRuntimeAccess; +} + +export function workspaceFs( + workspace: { fs: WorkspaceRuntimeFilesystem }, + options: WorkspaceFsOptions = {}, +): ReplCapability<{ + readFile(path: string): Promise; + readFileBytes(path: string): Promise; + writeFile(path: string, content: string | Uint8Array): Promise; + readdir(path?: string): Promise; + exists(path: string): Promise; + mkdir(path: string): Promise; + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + stat(path: string): Promise<{ size: number; mtime: number; isFile: boolean; isDirectory: boolean }>; +}> { + const root = options.root ?? "/"; + const access = options.access ?? "read-write"; + const confined = new WorkspaceRuntimeCapability(workspace.fs, root, access); + return capability( + { + readFile: (path: string) => confined.readFile(path), + readFileBytes: (path: string) => confined.readFileBytes(path), + writeFile: (path: string, content: string | Uint8Array) => + confined.writeFile(path, content), + readdir: (path = ".") => confined.readdir(path), + exists: (path: string) => confined.exists(path), + mkdir: (path: string) => confined.mkdir(path, { recursive: true }), + rm: (path: string, rmOptions?: { recursive?: boolean; force?: boolean }) => + confined.rm(path, rmOptions), + stat: async (path: string) => { + const stat = await confined.stat(path); + return { + size: stat.size, + mtime: stat.mtime, + isFile: stat.isFile, + isDirectory: stat.isDirectory, + }; + }, + }, + { + description: `Workspace filesystem (root ${root}, ${access})`, + docs: { + readFile: "readFile(path) → string", + readFileBytes: "readFileBytes(path) → Uint8Array", + writeFile: "writeFile(path, content) — content is a string or Uint8Array", + readdir: "readdir(path?) → string[] of entry names", + exists: "exists(path) → boolean", + mkdir: "mkdir(path) — recursive", + rm: "rm(path, { recursive?, force? })", + stat: "stat(path) → { size, mtime, isFile, isDirectory }", + }, + }, + ); +} diff --git a/packages/computer/src/repl/codec.test.ts b/packages/computer/src/repl/codec.test.ts new file mode 100644 index 00000000..5c13cc3c --- /dev/null +++ b/packages/computer/src/repl/codec.test.ts @@ -0,0 +1,160 @@ +// The REPL value codec: capability args, results, and grant data snapshots +// are stored as JSON in the session log, but cells traffic in real JS +// values (Dates, bytes, Maps). The codec is the single translation — it +// must round-trip everything it accepts and loudly reject what it can't, +// because a value that can't be re-served exactly would corrupt replay. +// +// encodeReplValue / decodeReplValue are deliberately self-contained plain +// functions: the same source runs host-side (imported) and isolate-side +// (injected into the runner module via Function.prototype.toString()). + +import { transformSync } from "esbuild"; +import { describe, expect, it } from "vitest"; + +import { decodeReplValue, encodeReplValue } from "./codec.js"; +import { replRunnerModule } from "./runner.js"; + +function roundTrip(value: unknown): unknown { + // Through real JSON, exactly like the SQLite log. + return decodeReplValue(JSON.parse(JSON.stringify(encodeReplValue(value)))); +} + +describe("encodeReplValue / decodeReplValue", () => { + it("passes JSON-native values through", () => { + const value = { a: 1, b: "two", c: [true, null, 3.5], d: { nested: "x" } }; + expect(roundTrip(value)).toEqual(value); + }); + + it("round-trips undefined, including inside objects and arrays", () => { + expect(roundTrip(undefined)).toBeUndefined(); + expect(roundTrip({ a: undefined })).toEqual({ a: undefined }); + expect(roundTrip([1, undefined, 2])).toEqual([1, undefined, 2]); + }); + + it("round-trips Dates to the millisecond", () => { + const date = new Date("2026-01-02T03:04:05.678Z"); + const back = roundTrip(date) as Date; + expect(back).toBeInstanceOf(Date); + expect(back.getTime()).toBe(date.getTime()); + }); + + it("round-trips bigint and non-JSON numbers", () => { + expect(roundTrip(123n)).toBe(123n); + expect(roundTrip(Number.NaN)).toBeNaN(); + expect(roundTrip(Infinity)).toBe(Infinity); + expect(roundTrip(-Infinity)).toBe(-Infinity); + }); + + it("round-trips Uint8Array and ArrayBuffer byte-exact", () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]); + const back = roundTrip(bytes) as Uint8Array; + expect(back).toBeInstanceOf(Uint8Array); + expect(Array.from(back)).toEqual(Array.from(bytes)); + + const buffer = bytes.slice().buffer; + const backBuffer = roundTrip(buffer) as ArrayBuffer; + expect(backBuffer).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(backBuffer))).toEqual(Array.from(bytes)); + }); + + it("round-trips Map and Set with non-string keys", () => { + const map = new Map([ + [1, "one"], + [{ k: 2 }, new Date(1000)], + ]); + const backMap = roundTrip(map) as Map; + expect(backMap).toBeInstanceOf(Map); + expect([...backMap.entries()]).toEqual([ + [1, "one"], + [{ k: 2 }, new Date(1000)], + ]); + + const set = new Set([1, "a", null]); + expect(roundTrip(set)).toEqual(set); + }); + + it("escapes plain objects that collide with the tag key", () => { + const value = { $repl: "date", v: 42 }; + expect(roundTrip(value)).toEqual(value); + }); + + it("preserves handle markers as plain data", () => { + // Handle markers are ordinary tagged objects minted by the recorder; + // the codec must carry them through unchanged, not decode them away. + const marker = { $repl: "handle", id: "a1h2" }; + expect(roundTrip(marker)).toEqual(marker); + }); + + it("rejects functions, class instances, and cycles with the fix named", () => { + expect(() => encodeReplValue(() => 1)).toThrow(/cannot be recorded|plain data/); + class Widget { + x = 1; + } + expect(() => encodeReplValue(new Widget())).toThrow(/cannot be recorded|plain data/); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => encodeReplValue(cyclic)).toThrow(/cycle/i); + }); + + it("is self-contained enough to inject into the runner template", () => { + // The runner evals these sources in an isolate with no module scope: + // they must not reference anything but their own parameters and body. + const source = `${encodeReplValue.toString()}\n${decodeReplValue.toString()}`; + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function( + `${source}\nreturn { encodeReplValue, decodeReplValue };`, + ) as () => { + encodeReplValue: typeof encodeReplValue; + decodeReplValue: typeof decodeReplValue; + }; + const injected = factory(); + const value = { when: new Date(5), bytes: new Uint8Array([9, 8]) }; + const back = injected.decodeReplValue( + JSON.parse(JSON.stringify(injected.encodeReplValue(value))), + ) as typeof value; + expect(back.when.getTime()).toBe(5); + expect(Array.from(back.bytes)).toEqual([9, 8]); + }); + + it("survives a consumer bundler's keepNames transform (wrangler's default)", () => { + // When a consumer bundles this package with esbuild keepNames on (as + // wrangler does), the codec function bodies get laced with __name(...) + // helper calls whose definition is hoisted to bundle scope — where + // Function.prototype.toString() can't see it. Reproduce that here and + // verify the runner module's prelude makes the injected source work. + const laced = transformSync( + `${encodeReplValue.toString()}\n${decodeReplValue.toString()}`, + { keepNames: true }, + ).code; + const start = laced.indexOf("function encodeReplValue"); + expect(start).toBeGreaterThan(0); + // Drop esbuild's inline helper definitions, keeping only what a real + // bundle's fn.toString() would return: bodies with unbound __name calls. + const stripped = laced.slice(start); + expect(stripped).toContain("__name("); + + type Codec = { + encodeReplValue: typeof encodeReplValue; + decodeReplValue: typeof decodeReplValue; + }; + const factory = (prelude: string): Codec => + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function( + `${prelude}\n${stripped}\nreturn { encodeReplValue, decodeReplValue };`, + )() as Codec; + + // Without the prelude, the laced source is broken — the production bug. + expect(() => factory("")).toThrow(/__name/); + + // The actual prelude emitted by the runner module repairs it. + const prelude = /const __name = [^;]*;/.exec(replRunnerModule())?.[0]; + expect(prelude).toBeDefined(); + const injected = factory(prelude ?? ""); + const value = { when: new Date(5), bytes: new Uint8Array([9, 8]) }; + const back = injected.decodeReplValue( + JSON.parse(JSON.stringify(injected.encodeReplValue(value))), + ) as typeof value; + expect(back.when.getTime()).toBe(5); + expect(Array.from(back.bytes)).toEqual([9, 8]); + }); +}); diff --git a/packages/computer/src/repl/codec.ts b/packages/computer/src/repl/codec.ts new file mode 100644 index 00000000..25efc2fa --- /dev/null +++ b/packages/computer/src/repl/codec.ts @@ -0,0 +1,199 @@ +// Tagged JSON-safe value codec for the REPL session log. +// +// Capability args, capability results, and grant data snapshots are stored +// as JSON rows in the workspace SQLite, but cells traffic in real JS +// values. Plain JSON would silently mangle Dates to strings and drop +// undefined — and a value that replays differently from how it ran is log +// corruption. So values are encoded into a tagged JSON-safe form on the +// way in and decoded back on the way out; anything the codec can't +// round-trip exactly is rejected loudly with the fix named. +// +// Both functions are deliberately self-contained (no imports, no module +// scope): the host imports them, and the isolate-side runner injects their +// source via Function.prototype.toString(). One definition, two runtimes. +// +// Encoding: JSON-native values pass through; everything else becomes a +// `{ $repl: tag, ... }` object. Plain objects that happen to contain a +// `$repl` key are escaped as `{ $repl: "obj", v }`. The hooks let the +// recorder swap live capability handles for `{ $repl: "handle", id }` +// markers (encode) and revive markers back into proxies or registry +// objects (decode) — the codec itself treats markers as opaque protocol +// data and carries them through unchanged. + +/** + * Encode a value into the tagged JSON-safe form. + * + * `replaceSpecial` may return a replacement (already JSON-safe — used for + * handle markers) or `undefined` to fall through to normal encoding. + * Throws on values that cannot round-trip (functions, class instances, + * cycles, …). + */ +export function encodeReplValue( + value: unknown, + replaceSpecial?: (candidate: unknown) => unknown, +): unknown { + const seen = new WeakSet(); + const reject = (what: string): never => { + throw new Error( + `A ${what} cannot be recorded in the session log. Values crossing a ` + + "capability call must be plain data: objects, arrays, strings, " + + "numbers, booleans, null, undefined, bigint, Date, Uint8Array, " + + "ArrayBuffer, Map, or Set. Keep other values in session variables — " + + "only what crosses the capability boundary is recorded.", + ); + }; + const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ""; + for (let i = 0; i < bytes.length; i += 4096) { + binary += String.fromCharCode(...bytes.subarray(i, i + 4096)); + } + return btoa(binary); + }; + const walk = (input: unknown): unknown => { + if (replaceSpecial) { + const replaced = replaceSpecial(input); + if (replaced !== undefined) return replaced; + } + if (input === null || typeof input === "boolean" || typeof input === "string") return input; + if (typeof input === "number") { + if (Number.isFinite(input) && !Object.is(input, -0)) return input; + return { $repl: "num", v: String(input) }; + } + if (input === undefined) return { $repl: "undefined" }; + if (typeof input === "bigint") return { $repl: "bigint", v: String(input) }; + if (typeof input === "function") return reject("function"); + if (typeof input === "symbol") return reject("symbol"); + if (typeof input !== "object") return reject(typeof input); + + if (input instanceof Date) { + const ms = input.getTime(); + return { $repl: "date", v: Number.isNaN(ms) ? null : ms }; + } + if (input instanceof Uint8Array) return { $repl: "u8", v: bytesToBase64(input) }; + if (input instanceof ArrayBuffer) { + return { $repl: "ab", v: bytesToBase64(new Uint8Array(input)) }; + } + if (ArrayBuffer.isView(input)) return reject(`${input.constructor.name} (use Uint8Array)`); + + if (seen.has(input)) { + throw new Error( + "A value with a reference cycle cannot be recorded in the session " + + "log. Flatten it before returning it across a capability call.", + ); + } + seen.add(input); + try { + if (input instanceof Map) { + const entries: unknown[] = []; + for (const [key, entry] of input) entries.push([walk(key), walk(entry)]); + return { $repl: "map", v: entries }; + } + if (input instanceof Set) { + const items: unknown[] = []; + for (const item of input) items.push(walk(item)); + return { $repl: "set", v: items }; + } + if (Array.isArray(input)) { + const items: unknown[] = []; + for (let i = 0; i < input.length; i++) items.push(walk(input[i])); + return items; + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + const name = + (input as { constructor?: { name?: string } }).constructor?.name ?? "class instance"; + return reject(`${name} instance`); + } + const record = input as Record; + const out: Record = {}; + for (const key of Object.keys(record)) out[key] = walk(record[key]); + if ("$repl" in out) return { $repl: "obj", v: out }; + return out; + } finally { + seen.delete(input); + } + }; + return walk(value); +} + +/** + * Decode a value from the tagged JSON-safe form. + * + * `reviveSpecial` may claim a tagged object (used to revive + * `{ $repl: "handle", id }` markers into live proxies) by returning a + * non-undefined value. Unclaimed handle markers pass through unchanged; + * any other unknown tag throws — an unreadable log entry is corruption + * and must be loud. + */ +export function decodeReplValue( + encoded: unknown, + reviveSpecial?: (tagged: { $repl: string } & Record) => unknown, +): unknown { + const base64ToBytes = (text: string): Uint8Array => { + const binary = atob(text); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; + }; + const walk = (input: unknown): unknown => { + if (input === null || typeof input !== "object") return input; + if (Array.isArray(input)) return input.map((item) => walk(item)); + const record = input as Record; + const tag = record.$repl; + if (typeof tag !== "string") { + const out: Record = {}; + for (const key of Object.keys(record)) out[key] = walk(record[key]); + return out; + } + if (reviveSpecial) { + const revived = reviveSpecial(record as { $repl: string } & Record); + if (revived !== undefined) return revived; + } + switch (tag) { + case "undefined": + return undefined; + case "num": + return record.v === "-0" ? -0 : Number(record.v); + case "bigint": + return BigInt(record.v as string); + case "date": + return new Date(record.v === null ? Number.NaN : (record.v as number)); + case "u8": + return base64ToBytes(record.v as string); + case "ab": { + const bytes = base64ToBytes(record.v as string); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + case "map": { + const map = new Map(); + for (const pair of record.v as [unknown, unknown][]) { + map.set(walk(pair[0]), walk(pair[1])); + } + return map; + } + case "set": { + const set = new Set(); + for (const item of record.v as unknown[]) set.add(walk(item)); + return set; + } + case "obj": { + // Escaped plain object: decode its values, but never re-interpret + // the container itself as tagged — its $repl key is user data. + const inner = record.v as Record; + const out: Record = {}; + for (const key of Object.keys(inner)) out[key] = walk(inner[key]); + return out; + } + case "handle": + // Unclaimed protocol marker: carry through for the layer above. + return record; + default: + throw new Error( + `Unknown tag ${JSON.stringify(tag)} in a recorded session value — ` + + "the log entry is unreadable. This session's log no longer " + + "matches this package version.", + ); + } + }; + return walk(encoded); +} diff --git a/packages/computer/src/repl/runner.ts b/packages/computer/src/repl/runner.ts index 2ba2ce37..d2b6b492 100644 --- a/packages/computer/src/repl/runner.ts +++ b/packages/computer/src/repl/runner.ts @@ -28,6 +28,8 @@ // deterministically, but replay re-waits real delays — committed // sleeps make replay slow, never wrong. +import { decodeReplValue, encodeReplValue } from "./codec.js"; + export const REPL_RUNNER_MODULE = "__repl_runner__.js"; export const REPL_CELLS_MODULE = "__repl_cells__.js"; @@ -56,12 +58,26 @@ const MAX_LOG_ENTRY_CHARS = 8_192; // The runner source. A template-built string (matching how the // worker-javascript backend ships its runtime module) so the package build -// needs no extra bundling step for isolate-side code. +// needs no extra bundling step for isolate-side code. The value codec is +// injected from its host-side definition via Function.prototype.toString() +// — one codec, both runtimes. export function replRunnerModule(): string { + const codecSource = `${encodeReplValue.toString()}\n${decodeReplValue.toString()}`; return ` import { WorkerEntrypoint } from "cloudflare:workers"; import cells from "./${REPL_CELLS_MODULE}"; +// Bundler-safety prelude. When the package consumer bundles with esbuild's +// keepNames option (wrangler's default), the codec sources injected below +// via Function.prototype.toString() arrive laced with __name(...) helper +// calls whose definition lives in the consumer bundle, not in this isolate. +// Define the same helper here so injected sources run either way. This +// module itself is a template string, so bundlers never transform it. +const __name = (target, value) => + Object.defineProperty(target, "name", { value, configurable: true }); + +${codecSource} + const fx = { mode: "record", queue: [], recorded: [] }; const DIVERGENCE = "__repl_replay_divergence__: "; @@ -92,6 +108,123 @@ function effect(kind, make) { return entry.value; } +// --- capability proxies ------------------------------------------------- +// +// Granted capabilities appear as globals. Every method call crosses the +// bridge in record mode and is logged (value, minted handle, or error — +// errors too, so a cell that caught one replays identically). In serve +// mode the log answers everything: zero live calls. Call identity is +// id + path + encoded args; any mismatch is a hard divergence error. + +let BRIDGE; +const PROXY_META = new WeakMap(); +const INJECTED = new Map(); + +function joinPath(path, prop) { + return path === "" ? prop : path + "." + prop; +} + +function makeCap(id, shape, recipe, path) { + const base = shape && shape.callable ? function () {} : {}; + const proxy = new Proxy(base, { + get(_target, prop) { + if (typeof prop !== "string") return undefined; + // Never look like a thenable: \`await cap\` must yield the proxy. + if (prop === "then") return undefined; + if (shape && !shape.opaque) { + if (shape.data && Object.prototype.hasOwnProperty.call(shape.data, prop)) { + return decodeReplValue(shape.data[prop]); + } + if (shape.children && Object.prototype.hasOwnProperty.call(shape.children, prop)) { + return makeCap(id, shape.children[prop], recipe, joinPath(path, prop)); + } + if (shape.methods && shape.methods.includes(prop)) { + return (...args) => doCall(id, joinPath(path, prop), args, recipe); + } + return undefined; + } + // Opaque surface (RPC stub): assume every property is a method. + return (...args) => doCall(id, joinPath(path, prop), args, recipe); + }, + apply(_target, _thisArg, args) { + return doCall(id, path, args, recipe); + }, + }); + PROXY_META.set(proxy, { id, recipe }); + return proxy; +} + +function capError(e) { + const error = new Error(e.message); + error.name = e.name; + if (e.kind) error.replKind = e.kind; + return error; +} + +async function doCall(id, path, args, recipe) { + // Arg encoding failures are deterministic (same code, same throw), so + // they need no effect entry — replay reproduces them from code alone. + const encArgs = encodeReplValue(args, (candidate) => { + const meta = PROXY_META.get(candidate); + if (meta) return { $repl: "handle", id: meta.id, recipe: meta.recipe }; + return undefined; + }); + const callId = id + "|" + (path === "" ? "()" : path) + "|" + JSON.stringify(encArgs); + if (fx.mode === "record") { + const reply = await BRIDGE.invoke(id, path, encArgs, recipe); + if (!reply.ok) { + fx.recorded.push({ kind: "cap", value: { call: callId, r: { t: "e", e: reply.error } } }); + throw capError(reply.error); + } + if (reply.handle) { + fx.recorded.push({ kind: "cap", value: { call: callId, r: { t: "h", h: reply.handle } } }); + return makeCap(reply.handle.id, reply.handle.shape, reply.handle.recipe, ""); + } + fx.recorded.push({ kind: "cap", value: { call: callId, r: { t: "v", v: reply.value } } }); + return decodeReplValue(reply.value); + } + const entry = fx.queue.shift(); + if (!entry || entry.kind !== "cap" || !entry.value || entry.value.call !== callId) { + const found = !entry + ? "nothing" + : entry.kind !== "cap" + ? "a " + JSON.stringify(entry.kind) + " effect" + : "a different call (" + entry.value.call + ")"; + throw new Error( + DIVERGENCE + "expected the recorded capability call " + callId + ", log had " + found + + ". Committed cells must replay exactly; this session's log no longer matches its code." + ); + } + const r = entry.value.r; + if (r.t === "e") throw capError(r.e); + if (r.t === "h") return makeCap(r.h.id, r.h.shape, r.h.recipe, ""); + return decodeReplValue(r.v); +} + +// (Re)inject capability globals for one cell. Replayed cells get the grant +// shapes they were recorded under — including their data snapshots — and +// the new cell gets the current attachment's. Injection happens at every +// cell start in both modes, so grants win over leftover same-name bindings +// identically live and on replay. +function injectGrants(shapes) { + // Restore whatever a grant name shadowed (e.g. the ambient-fetch error + // when a capability was granted as \`fetch\`), then remove our proxies. + for (const [name, prior] of INJECTED) { + try { + if (prior) Object.defineProperty(globalThis, name, prior); + else delete globalThis[name]; + } catch {} + } + INJECTED.clear(); + if (!shapes) return; + for (const name of Object.keys(shapes)) { + INJECTED.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + globalThis[name] = makeCap(name, shapes[name], name, ""); + } +} + +// --- recorded nondeterminism shims --------------------------------------- + Math.random = () => effect("random", realRandom); if (realRandomUUID) crypto.randomUUID = () => effect("uuid", realRandomUUID); @@ -128,6 +261,18 @@ if (globalThis.performance && typeof performance.now === "function") { performance.now = () => effect("perf-now", realPerfNow); } +// Ambient network is deny-by-default (globalOutbound is null); replace +// fetch so the failure names the fix instead of a connection error. +if (typeof globalThis.fetch === "function") { + globalThis.fetch = () => { + throw new Error( + "No ambient network in REPL sessions — egress is deny-by-default. " + + "Network access arrives only as a granted fetch capability: call that " + + "by its granted name, or ask the host to grant one (fetchCapability())." + ); + }; +} + // GC timing (WeakRef/FinalizationRegistry) and cross-isolate cache state // cannot replay; remove the globals so use is a loud ReferenceError. for (const name of ["WeakRef", "FinalizationRegistry", "caches"]) { @@ -163,6 +308,8 @@ for (const level of ["log", "info", "debug", "warn", "error"]) { function inspect(value, depth) { if (value === null) return "null"; + const capMeta = PROXY_META.get(value); + if (capMeta) return "[capability handle: " + capMeta.recipe + "]"; const t = typeof value; if (t === "string") return depth === 0 ? value : JSON.stringify(value); if (t === "number" || t === "boolean" || t === "bigint" || t === "undefined") return String(value); @@ -189,7 +336,9 @@ function describeError(error, kind) { name: isError ? error.name : "Error", message: String(isError ? error.message : error), traceback: isError && error.stack ? String(error.stack) : undefined, - kind, + // Bridge-classified failures (stale-lease, not-granted, …) carry their + // kind on the thrown error; pass it through to the result. + kind: kind ?? (isError && error.replKind ? error.replKind : undefined), }; } @@ -198,10 +347,14 @@ export default class ReplRunner extends WorkerEntrypoint { // final cell in record mode. Returns a structured outcome; never throws // for cell-level failures (structure survives the RPC boundary, thrown // errors don't). - async run(effectLog) { + async run(effectLog, bridge, grants) { + BRIDGE = bridge; + const perCell = (grants && grants.perCell) || []; + const current = (grants && grants.current) || {}; for (let i = 0; i < cells.length - 1; i++) { fx.mode = "serve"; fx.queue = (effectLog[i] || []).slice(); + injectGrants(perCell[i]); try { await cells[i](); } catch (error) { @@ -224,6 +377,7 @@ export default class ReplRunner extends WorkerEntrypoint { fx.recorded = []; logs.entries = []; logs.dropped = 0; + injectGrants(current); let value; try { value = await cells[cells.length - 1](); @@ -243,6 +397,9 @@ export default class ReplRunner extends WorkerEntrypoint { results: [], }; try { + // A capability handle is not a value — it structured-clones as an + // empty shell — so it ships as a rendering instead. + if (PROXY_META.has(value)) throw new Error("capability handle"); structuredClone(value); outcome.value = value; outcome.hasValue = true; diff --git a/packages/computer/src/repl/session.ts b/packages/computer/src/repl/session.ts index 579dbc9c..ca86bebc 100644 --- a/packages/computer/src/repl/session.ts +++ b/packages/computer/src/repl/session.ts @@ -13,6 +13,8 @@ import type { Database } from "@cloudflare/dofs"; import type { WorkspaceRuntimeLoader } from "../runtime/types.js"; +import { ReplCapabilityBridge } from "./bridge.js"; +import type { ReplCapability, ReplCapabilityShape } from "./capability.js"; import { REPL_CELLS_MODULE, REPL_RUNNER_MODULE, @@ -31,7 +33,24 @@ export interface ReplSessionOptions { name: string; db: Database; loader: WorkspaceRuntimeLoader; + /** + * Capabilities granted to this attachment, by global name. Grants are + * attach-time: they apply to evals started after this attach. Committed + * cells always replay against the grants they were recorded with. + */ + capabilities?: Record; timeoutMs?: number; + /** + * Per-effect recorded-value ceiling in JSON bytes (default 1 MiB). + * Oversized capability results are rejected, never truncated — the error + * tells the model to write large data to workspace files and pass paths. + * Lower it to push data toward files earlier (every recorded effect is + * reloaded on every future eval, so lean logs keep evals fast) or when a + * harness meters session weight per tenant. There is little headroom to + * raise it: effects are SQLite rows, and Durable Object SQLite caps a + * row at ~2 MB. + */ + maxEffectBytes?: number; compatibilityDate?: string; now?: () => number; } @@ -44,6 +63,8 @@ interface CommittedCell { code: string; transformed: string; effects: ReplEffect[]; + /** Grant shapes (with data snapshots) this cell was recorded under. */ + grants: Record; } interface RunOutcome { @@ -57,8 +78,17 @@ interface RunOutcome { value?: unknown; } +interface RunnerGrants { + perCell: Array>; + current: Record; +} + interface RunnerEntrypoint { - run(effectLog: ReplEffect[][]): Promise; + run( + effectLog: ReplEffect[][], + bridge: ReplCapabilityBridge, + grants: RunnerGrants, + ): Promise; [Symbol.dispose]?: () => void; } @@ -71,6 +101,10 @@ export class ReplSession { readonly #timeoutMs: number; readonly #compatibilityDate: string; readonly #now: () => number; + readonly #maxEffectBytes: number | undefined; + // Session-lifetime capability bridge: attach() swaps its root grants, + // while handles the session has acquired persist until host restart. + readonly #bridge: ReplCapabilityBridge; // Committed log, lazily loaded from SQLite; the DO is the single writer. #cells: CommittedCell[] | undefined; // Tail promise serializing evals on this session. @@ -83,9 +117,24 @@ export class ReplSession { this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.#compatibilityDate = options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE; this.#now = options.now ?? Date.now; + this.#maxEffectBytes = options.maxEffectBytes; + this.#bridge = new ReplCapabilityBridge( + options.capabilities ?? {}, + this.#maxEffectBytes === undefined ? {} : { maxEffectBytes: this.#maxEffectBytes }, + ); initializeReplSchema(this.#db); } + /** + * Replace this session's grants. Applies to evals started afterwards; + * an eval already in flight keeps the grants it started with. Handles + * acquired earlier stay alive, but are only callable while the root + * grant they descend from is still attached. + */ + attach(capabilities: Record): void { + this.#bridge.attach(capabilities); + } + eval(code: string, options?: ReplEvalOptions): Promise { const run = this.#tail.then(() => this.#eval(code, options), () => this.#eval(code, options)); this.#tail = run.catch(() => undefined); @@ -95,6 +144,12 @@ export class ReplSession { async #eval(code: string, options?: ReplEvalOptions): Promise { const cells = this.#load(); const executionCount = cells.length + 1; + const bridge = this.#bridge; + // Pin the attachment's grant shapes for this whole eval: an attach() + // racing a queued eval must not swap grants under a running cell or + // its commit row. (attach() replaces the shapes object wholesale, so + // this reference stays internally consistent.) + const grantShapes = bridge.shapes(); let transformed: string; try { @@ -113,10 +168,15 @@ export class ReplSession { } const timeoutMs = options?.timeoutMs ?? this.#timeoutMs; - const outcome = await this.#run(cells, transformed, timeoutMs); + const outcome = await this.#run(cells, transformed, timeoutMs, bridge, grantShapes); if (outcome.ok) { - this.#commit(cells, { code, transformed, effects: outcome.effects ?? [] }); + this.#commit(cells, { + code, + transformed, + effects: outcome.effects ?? [], + grants: grantShapes, + }); const result: ReplExecutionResult = { code, logs: outcome.logs ?? EMPTY_LOGS(), @@ -137,7 +197,13 @@ export class ReplSession { }; } - async #run(cells: CommittedCell[], transformed: string, timeoutMs: number): Promise { + async #run( + cells: CommittedCell[], + transformed: string, + timeoutMs: number, + bridge: ReplCapabilityBridge, + grantShapes: Record, + ): Promise { const modules: Record = { [REPL_RUNNER_MODULE]: replRunnerModule(), [REPL_CELLS_MODULE]: replCellsModule(cells.length + 1), @@ -164,7 +230,11 @@ export class ReplSession { }); try { const effectLog = cells.map((cell) => cell.effects); - const run = Promise.resolve().then(() => entrypoint.run(effectLog)); + const grants: RunnerGrants = { + perCell: cells.map((cell) => cell.grants), + current: grantShapes, + }; + const run = Promise.resolve().then(() => entrypoint.run(effectLog, bridge, grants)); // If the timeout wins the race, the losing run promise rejects later // (its isolate is disposed) with nobody awaiting it — swallow that so // it can't surface as an unhandled rejection. @@ -197,8 +267,8 @@ export class ReplSession { #load(): CommittedCell[] { if (this.#cells !== undefined) return this.#cells; - const rows = this.#db.all<{ seq: number; code: string; transformed: string }>( - "SELECT seq, code, transformed FROM repl_cells WHERE session = ? ORDER BY seq", + const rows = this.#db.all<{ seq: number; code: string; transformed: string; grants: string }>( + "SELECT seq, code, transformed, grants FROM repl_cells WHERE session = ? ORDER BY seq", this.name, ); const effectRows = this.#db.all<{ cell_seq: number; kind: string; value: string }>( @@ -215,23 +285,25 @@ export class ReplSession { code: row.code, transformed: row.transformed, effects: effectsBySeq.get(row.seq) ?? [], + grants: JSON.parse(row.grants) as Record, })); return this.#cells; } #commit(cells: CommittedCell[], cell: CommittedCell): void { // Effect values are stored verbatim — they are replay input and must - // never be truncated. Today's effects are ≤36-byte scalars by - // construction; future effect sources with sizable results (capability - // calls) must REJECT the cell, not truncate the value. + // never be truncated. Shim effects are ≤36-byte scalars by + // construction; capability results are size-guarded at the bridge, + // which REJECTS the call (failing the cell) rather than truncating. const seq = cells.length + 1; this.#db.transactionSync(() => { this.#db.run( - "INSERT INTO repl_cells (session, seq, code, transformed, created_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO repl_cells (session, seq, code, transformed, grants, created_at) VALUES (?, ?, ?, ?, ?, ?)", this.name, seq, cell.code, cell.transformed, + JSON.stringify(cell.grants), this.#now(), ); cell.effects.forEach((effect, index) => { @@ -270,6 +342,7 @@ function initializeReplSchema(db: Database): void { seq INTEGER NOT NULL, code TEXT NOT NULL, transformed TEXT NOT NULL, + grants TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL, PRIMARY KEY (session, seq) )`, diff --git a/packages/computer/src/repl/types.ts b/packages/computer/src/repl/types.ts index 2b8f3fb2..11c36215 100644 --- a/packages/computer/src/repl/types.ts +++ b/packages/computer/src/repl/types.ts @@ -32,7 +32,28 @@ export type ReplErrorKind = * The cell exceeded its wall-clock or CPU budget and was destroyed. * Nothing was committed; split the work or raise timeoutMs. */ - | "timeout"; + | "timeout" + /** + * New code used a handle whose live object died with a session host + * restart. The error message carries the handle's acquisition recipe — + * re-run it to get a fresh handle. Committed cells replay from the log + * and never hit this. + */ + | "stale-lease" + /** + * New code called a capability that is not granted in the current + * attachment — either directly, or through a handle descending from a + * root grant that was since revoked. Grants are attach-time: the host + * must re-attach the session with the capability before new code can + * use it. + */ + | "not-granted" + /** + * A capability call returned more than the per-call recorded ceiling. + * Recorded values are replay input and are never truncated, so the cell + * failed instead. Write large data to workspace files and return a path. + */ + | "oversized-result"; export interface ReplExecutionError { name: string; diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 8f078843..ba07e59b 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -599,6 +599,10 @@ export class Workspace { * replayable log in this workspace's SQLite — it has no open/close * lifecycle and costs nothing while idle. Evals on one session are * serialized; use distinct names for parallel work. + * + * Capability grants are attach-time: every repl() call re-attaches with + * exactly the capabilities passed here (none means none). Committed + * cells always replay against the grants they were recorded with. */ repl(name: string, options: Omit): ReplSession { if (name.length === 0) throw new Error("Workspace repl session name must be non-empty."); @@ -606,6 +610,8 @@ export class Workspace { if (session === undefined) { session = new ReplSession({ ...options, name, db: this.#db, now: this.#now }); this.#replSessions.set(name, session); + } else { + session.attach(options.capabilities ?? {}); } return session; } diff --git a/packages/computer/tests/repl-capabilities.test.ts b/packages/computer/tests/repl-capabilities.test.ts new file mode 100644 index 00000000..fc23293f --- /dev/null +++ b/packages/computer/tests/repl-capabilities.test.ts @@ -0,0 +1,551 @@ +// Durable REPL sessions — capability behavior tests. +// +// Runs against real workerd with a real worker_loaders binding. The core +// discipline under test: capability calls execute live exactly once, are +// recorded in the session log, and replay — including across a simulated +// Durable Object eviction — answers every call from the log with ZERO live +// calls. Counters live on the test DO so restarts can't reset them. + +import { env, runInDurableObject } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import type { ReplHostDO } from "./repl-worker.js"; + +let sessionCounter = 0; + +function uniqueSession(): string { + sessionCounter += 1; + return `cap-session-${sessionCounter}`; +} + +function host() { + const id = env.HOST.newUniqueId(); + return env.HOST.get(id); +} + +describe("REPL capabilities", () => { + it("serves replayed capability calls from the log with zero live calls", async () => { + const stub = host(); + const session = uniqueSession(); + + // Live cell: the call really fires, and the result is a real value — + // including a Date that must survive the log round-trip. + const first = await stub.replEvalWith( + ["weather"], + session, + `const report = await weather.get("london"); + report.temp`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toBe(21.5); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + + // Second eval replays cell 1 — the recorded result must be served, not + // re-fetched, and the restored value must be the real thing (Date and + // all), not a JSON shadow. + const second = await stub.replEvalWith( + ["weather"], + session, + `[report.city, report.asOf instanceof Date, report.asOf.getTime()]`, + ); + expect(second.error).toBeUndefined(); + expect(second.value).toEqual(["london", true, 1735732800000]); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + + // Eviction: in-memory session state is gone, the log survives. Replay + // rebuilds the same state, still without touching the capability. + await stub.restart(); + const third = await stub.replEvalWith(["weather"], session, `report.temp * 2`); + expect(third.error).toBeUndefined(); + expect(third.value).toBe(43); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + }); + + it("grants bare functions as callables", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["sendEmail"], + session, + `const receipt = await sendEmail("a@example.com", "Hello"); + receipt`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toEqual({ + queued: true, + id: "msg-1", + to: "a@example.com", + subject: "Hello", + }); + expect(await stub.counts()).toEqual({ sendEmail: 1 }); + + await stub.restart(); + const second = await stub.replEvalWith(["sendEmail"], session, `receipt.id`); + expect(second.value).toBe("msg-1"); + expect(await stub.counts()).toEqual({ sendEmail: 1 }); + }); + + it("chains returned handles across cells and replays them with zero live calls", async () => { + const stub = host(); + const session = uniqueSession(); + + // A class-instance capability returning a class-instance handle. + const first = await stub.replEvalWith( + ["browser"], + session, + `const tab = await browser.newTab("https://example.com"); + (await tab.read()).title`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toBe("Title of https://example.com"); + + const second = await stub.replEvalWith( + ["browser"], + session, + `const outcome = await tab.click("#buy"); + outcome`, + ); + expect(second.error).toBeUndefined(); + expect(second.value).toEqual({ clicked: "#buy" }); + expect(await stub.counts()).toEqual({ + "browser.newTab": 1, + "tab.read": 1, + "tab.click": 1, + }); + + // Across an eviction, both cells (and the handle chain inside them) + // replay purely from the log. + await stub.restart(); + const third = await stub.replEvalWith(["browser"], session, `outcome.clicked`); + expect(third.error).toBeUndefined(); + expect(third.value).toBe("#buy"); + expect(await stub.counts()).toEqual({ + "browser.newTab": 1, + "tab.read": 1, + "tab.click": 1, + }); + }); + + it("answers new calls on dead handles with a stale-lease error carrying the recipe", async () => { + const stub = host(); + const session = uniqueSession(); + + await stub.replEvalWith( + ["browser"], + session, + `const tab = await browser.newTab("https://example.com"); + await tab.read()`, + ); + + // Eviction kills the live tab object; the replayed proxy still exists. + await stub.restart(); + const stale = await stub.replEvalWith(["browser"], session, `await tab.click("#buy")`); + expect(stale.error?.kind).toBe("stale-lease"); + expect(stale.error?.name).toBe("StaleLeaseError"); + // The error carries the full acquisition recipe — how to get it back. + expect(stale.error?.message).toContain('browser.newTab("https://example.com")'); + expect(await stub.counts()).toEqual({ "browser.newTab": 1, "tab.read": 1 }); + + // The failed cell was not committed; re-acquiring works. + const recover = await stub.replEvalWith( + ["browser"], + session, + `const tab2 = await browser.newTab("https://example.com"); + (await tab2.click("#buy")).clicked`, + ); + expect(recover.error).toBeUndefined(); + expect(recover.value).toBe("#buy"); + expect(await stub.counts()).toEqual({ + "browser.newTab": 2, + "tab.read": 1, + "tab.click": 1, + }); + }); + + it("cuts handles when the root they descend from is revoked", async () => { + const stub = host(); + const session = uniqueSession(); + + await stub.replEvalWith( + ["browser"], + session, + `const tab = await browser.newTab("https://example.com"); + await tab.read()`, + ); + + // Same host (no restart) — the handle is alive — but the browser + // grant is gone, so everything descending from it is too. + const revoked = await stub.replEvalWith([], session, `await tab.click("#buy")`); + expect(revoked.error?.kind).toBe("not-granted"); + expect(revoked.error?.message).toContain('descends from "browser"'); + expect(await stub.counts()).toEqual({ "browser.newTab": 1, "tab.read": 1 }); + + // Re-granting the root restores the very same handle. + const restored = await stub.replEvalWith(["browser"], session, `await tab.click("#buy")`); + expect(restored.error).toBeUndefined(); + expect(restored.value).toEqual({ clicked: "#buy" }); + }); + + it("replays recorded capability errors without re-firing the call", async () => { + const stub = host(); + const session = uniqueSession(); + + // The cell catches the failure and commits — so the error itself is + // now part of the log and must replay identically. + const first = await stub.replEvalWith( + ["weather"], + session, + `let caught = null; + try { await weather.flaky(); } catch (error) { caught = error.message; } + caught`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toBe("boom 1"); + expect(await stub.counts()).toEqual({ "weather.flaky": 1 }); + + await stub.restart(); + const second = await stub.replEvalWith(["weather"], session, `caught`); + expect(second.value).toBe("boom 1"); + // Replay served the recorded error; the flaky call never re-fired. + expect(await stub.counts()).toEqual({ "weather.flaky": 1 }); + }); + + it("fails loudly when recorded capability args no longer match the code", async () => { + const stub = host(); + const session = uniqueSession(); + + await stub.replEvalWith(["weather"], session, `await weather.get("london")`); + await stub.corruptCapCallArgs(session, "london", "paris"); + await stub.restart(); + + const diverged = await stub.replEvalWith(["weather"], session, `1 + 1`); + expect(diverged.error?.kind).toBe("replay-divergence"); + expect(diverged.error?.message).toContain("capability call"); + }); + + it("snapshots granted data per cell: replay sees original values, new cells see current", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith(["configV1"], session, `const url1 = config.apiUrl; url1`); + expect(first.value).toBe("https://v1.example"); + + // Re-attach with different data under the same name: the old binding + // (from cell 1's snapshot) and the new attachment coexist. + const second = await stub.replEvalWith( + ["configV2"], + session, + `[url1, config.apiUrl, config.retries]`, + ); + expect(second.value).toEqual(["https://v1.example", "https://v2.example", 5]); + + // After eviction both cells replay against their own recorded grants. + await stub.restart(); + const third = await stub.replEvalWith(["configV2"], session, `[url1, config.apiUrl]`); + expect(third.value).toEqual(["https://v1.example", "https://v2.example"]); + }); + + it("rejects calls to revoked capabilities while old cells keep replaying", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["weather"], + session, + `const w = weather; + const t = (await weather.get("oslo")).temp; + t`, + ); + expect(first.value).toBe(21.5); + + // Attach WITHOUT weather: cell 1 still replays fine (zero live calls). + const second = await stub.replEvalWith([], session, `t * 2`); + expect(second.error).toBeUndefined(); + expect(second.value).toBe(43); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + + // The global is gone for new code… + const third = await stub.replEvalWith([], session, `typeof weather`); + expect(third.value).toBe("undefined"); + + // …and a kept alias gets the structured answer, not a dead stub. + const fourth = await stub.replEvalWith([], session, `await w.get("oslo")`); + expect(fourth.error?.kind).toBe("not-granted"); + expect(fourth.error?.message).toContain('"weather" is not granted'); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + }); + + it("denies ambient fetch with an error that names the fix", async () => { + const stub = host(); + const session = uniqueSession(); + const result = await stub.replEvalWith([], session, `await fetch("https://x.example/")`); + expect(result.error?.message).toContain("No ambient network"); + expect(result.error?.message).toContain("fetchCapability"); + + // Granting a capability AS `fetch` shadows the guidance; revoking it + // restores the guidance instead of leaving `fetch` deleted. + const granted = await stub.replEvalWith(["fetchBlocked"], session, `typeof fetch`); + expect(granted.value).toBe("function"); + const revoked = await stub.replEvalWith([], session, `await fetch("https://x.example/")`); + expect(revoked.error?.message).toContain("No ambient network"); + }); + + it("enforces the fetch capability allowlist", async () => { + const stub = host(); + const session = uniqueSession(); + + const denied = await stub.replEvalWith( + ["fetchBlocked"], + session, + `await fetch("https://evil.example/steal")`, + ); + expect(denied.error?.name).toBe("EgressDeniedError"); + expect(denied.error?.message).toContain("evil.example"); + expect(denied.error?.message).toContain("allowed.example"); + + // The failed cell did not commit; the session is still usable. + const next = await stub.replEvalWith(["fetchBlocked"], session, `1 + 1`); + expect(next.value).toBe(2); + expect(next.executionCount).toBe(1); + }); + + it("routes granted fetch through the gateway and replays responses without refetching", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["fetchSelf"], + session, + `const res = await fetch("https://service.internal/hello"); + const body = await res.json(); + [res.status, res.ok, body.ok, body.path]`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toEqual([200, true, true, "/hello"]); + expect(await stub.counts()).toEqual({ "gateway.fetch": 1 }); + + await stub.restart(); + const second = await stub.replEvalWith(["fetchSelf"], session, `body.path`); + expect(second.value).toBe("/hello"); + expect(await stub.counts()).toEqual({ "gateway.fetch": 1 }); + }); + + it("records filesystem effects once and never re-fires them on replay", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["fs"], + session, + `await fs.mkdir("/notes"); + await fs.writeFile("/notes/log.txt", "alpha"); + await fs.readFile("/notes/log.txt")`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toBe("alpha"); + expect(await stub.counts()).toMatchObject({ "fs.writeFile": 1, "fs.readFile": 1 }); + + const second = await stub.replEvalWith( + ["fs"], + session, + `const current = await fs.readFile("/notes/log.txt"); + await fs.writeFile("/notes/log.txt", current + "+beta"); + await fs.readFile("/notes/log.txt")`, + ); + expect(second.value).toBe("alpha+beta"); + expect(await stub.counts()).toMatchObject({ "fs.writeFile": 2, "fs.readFile": 3 }); + + // Replay after eviction re-fires nothing: same content, same counters + // (+1 read for the new cell's own live read). + await stub.restart(); + const third = await stub.replEvalWith(["fs"], session, `await fs.readFile("/notes/log.txt")`); + expect(third.value).toBe("alpha+beta"); + expect(await stub.counts()).toMatchObject({ "fs.writeFile": 2, "fs.readFile": 4 }); + }); + + it("rejects oversized capability results without committing or truncating", async () => { + const stub = host(); + const session = uniqueSession(); + + const oversized = await stub.replEvalWith( + ["big"], + session, + `await big.blob(5000)`, + undefined, + { maxEffectBytes: 1024 }, + ); + expect(oversized.error?.kind).toBe("oversized-result"); + expect(oversized.error?.name).toBe("OversizedResultError"); + expect(oversized.error?.message).toContain("1024"); + + // Nothing was committed — and small results still work. + const next = await stub.replEvalWith(["big"], session, `await big.blob(3)`); + expect(next.value).toBe("xxx"); + expect(next.executionCount).toBe(1); + }); + + it("exposes a safe proxy surface: awaitable, symbol-blind, rendered as a handle", async () => { + const stub = host(); + const session = uniqueSession(); + + // `await cap` must yield the proxy itself (the `then` guard), symbol + // properties must read as undefined, and a capability as a cell's + // value must render as a handle reference, not structured-clone. + const first = await stub.replEvalWith( + ["weather"], + session, + `const w = await weather; + const viaAwait = (await w.get("lisbon")).city; + const symbolProp = typeof weather[Symbol.iterator]; + [viaAwait, symbolProp]`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toEqual(["lisbon", "undefined"]); + expect(await stub.counts()).toEqual({ "weather.get": 1 }); + + const second = await stub.replEvalWith(["weather"], session, `weather`); + expect(second.error).toBeUndefined(); + expect(second.value).toBeUndefined(); + expect(second.results?.[0]?.text).toContain("capability handle"); + expect(second.results?.[0]?.text).toContain("weather"); + }); + + it("supports nested capability objects (children) beside plain data", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["api"], + session, + `const names = await api.users.list(); + [api.version, names]`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toEqual(["2.0", ["ada", "grace"]]); + expect(await stub.counts()).toEqual({ "api.users.list": 1 }); + + // Replay across eviction: the nested call is served from the log. + await stub.restart(); + const second = await stub.replEvalWith(["api"], session, `[api.version, names.length]`); + expect(second.error).toBeUndefined(); + expect(second.value).toEqual(["2.0", 2]); + expect(await stub.counts()).toEqual({ "api.users.list": 1 }); + }); + + it("treats every property of an unreflectable stub as a method (opaque surface)", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith(["stub"], session, `await stub.ping()`); + expect(first.error).toBeUndefined(); + expect(first.value).toBe("pong"); + expect(await stub.counts()).toEqual({ "stub.ping": 1 }); + + // A property the target never materializes: the live call fails with + // the recipe-qualified name, the cell can catch it, and the caught + // error is recorded — so the cell commits and replays identically. + const second = await stub.replEvalWith( + ["stub"], + session, + `let caught; + try { await stub.missing(); } catch (e) { caught = e.message; } + caught`, + ); + expect(second.error).toBeUndefined(); + expect(String(second.value)).toContain("stub.missing is not a function"); + + await stub.restart(); + const third = await stub.replEvalWith(["stub"], session, `[typeof caught, await stub.ping()]`); + expect(third.error).toBeUndefined(); + expect(third.value).toEqual(["string", "pong"]); + expect(await stub.counts()).toEqual({ "stub.ping": 2 }); + }); + + it("mints returned functions as callable handles, stale after host restart", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["tools"], + session, + `const greet = await tools.makeGreeter("Ada"); + await greet("Hello")`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toBe("Hello, Ada!"); + + // The function handle persists as a session binding and stays live. + const second = await stub.replEvalWith(["tools"], session, `await greet("Hi")`); + expect(second.error).toBeUndefined(); + expect(second.value).toBe("Hi, Ada!"); + expect(await stub.counts()).toEqual({ "tools.makeGreeter": 1, "tools.greet": 2 }); + + // Restart kills live handles: replay still rebuilds `greet` (both prior + // calls served from the log), but a NEW call on it is a stale lease + // carrying the acquisition recipe. + await stub.restart(); + const third = await stub.replEvalWith(["tools"], session, `await greet("Yo")`); + expect(third.error?.kind).toBe("stale-lease"); + expect(third.error?.message).toContain('tools.makeGreeter("Ada")'); + expect(await stub.counts()).toEqual({ "tools.makeGreeter": 1, "tools.greet": 2 }); + }); + + it("covers the whole workspaceFs surface, replayed with zero live calls", async () => { + const stub = host(); + const session = uniqueSession(); + + const first = await stub.replEvalWith( + ["fs"], + session, + `await fs.mkdir("/data"); + await fs.writeFile("/data/a.txt", "alpha"); + const names = await fs.readdir("/data"); + const present = await fs.exists("/data/a.txt"); + const absent = await fs.exists("/data/nope"); + const st = await fs.stat("/data/a.txt"); + const bytes = await fs.readFileBytes("/data/a.txt"); + await fs.rm("/data/a.txt"); + const afterRm = await fs.exists("/data/a.txt"); + [names, present, absent, st.isFile, st.size, bytes instanceof Uint8Array, bytes.length, afterRm]`, + ); + expect(first.error).toBeUndefined(); + expect(first.value).toEqual([["a.txt"], true, false, true, 5, true, 5, false]); + + const liveFsCalls = async () => { + const counts = await stub.counts(); + return Object.entries(counts) + .filter(([name]) => name.startsWith("fs.")) + .reduce((sum, [, n]) => sum + n, 0); + }; + const afterLive = await liveFsCalls(); + expect(afterLive).toBeGreaterThan(0); + + // Eviction + replay: every fs effect is served from the log. + await stub.restart(); + const second = await stub.replEvalWith(["fs"], session, `[present, absent, afterRm]`); + expect(second.error).toBeUndefined(); + expect(second.value).toEqual([true, false, false]); + expect(await liveFsCalls()).toBe(afterLive); + }); + + it("rejects unrecordable call arguments with the fix named", async () => { + const stub = host(); + const session = uniqueSession(); + + const bad = await stub.replEvalWith(["weather"], session, `await weather.get(() => 1)`); + expect(bad.error?.message).toMatch(/function .*cannot be recorded|A function cannot be recorded/); + // The call never crossed the bridge. + expect(await stub.counts()).toEqual({}); + + const next = await stub.replEvalWith(["weather"], session, `(await weather.get("rome")).city`); + expect(next.value).toBe("rome"); + expect(next.executionCount).toBe(1); + }); +}); + +// Silence the unused-import lint for the DO type-only import. +export type { ReplHostDO }; + +// Keep runInDurableObject imported for later seams that reach into storage. +void runInDurableObject; diff --git a/packages/computer/tests/repl-worker.ts b/packages/computer/tests/repl-worker.ts index d431a582..99448a50 100644 --- a/packages/computer/tests/repl-worker.ts +++ b/packages/computer/tests/repl-worker.ts @@ -8,18 +8,59 @@ import { DurableObject } from "cloudflare:workers"; import type { DurableObjectStorageLike, + ReplCapability, ReplEvalOptions, ReplExecutionResult, } from "../src/index.js"; -import { Workspace } from "../src/index.js"; +import { capability, fetchCapability, Workspace, workspaceFs } from "../src/index.js"; export interface Env { HOST: DurableObjectNamespace; LOADER: WorkerLoader; + SELF: Fetcher; +} + +// A page handle vended by the browser fixture: methods live on the +// prototype, exactly like a real SDK class. +class TabFixture { + readonly #url: string; + readonly #count: (name: string) => void; + + constructor(url: string, count: (name: string) => void) { + this.#url = url; + this.#count = count; + } + + read(): { url: string; title: string } { + this.#count("tab.read"); + return { url: this.#url, title: `Title of ${this.#url}` }; + } + + click(selector: string): { clicked: string } { + this.#count("tab.click"); + return { clicked: selector }; + } +} + +class BrowserFixture { + readonly #count: (name: string) => void; + + constructor(count: (name: string) => void) { + this.#count = count; + } + + newTab(url: string): TabFixture { + this.#count("browser.newTab"); + return new TabFixture(url, this.#count); + } } export class ReplHostDO extends DurableObject { #workspace: Workspace | undefined; + // Live-call counters for capability fixtures. On the DO (not the + // Workspace) so restart() keeps them — they count real side effects, + // which survive process boundaries in the world too. + #counts: Record = {}; #ws(): Workspace { this.#workspace ??= new Workspace({ @@ -28,6 +69,166 @@ export class ReplHostDO extends DurableObject { return this.#workspace; } + #count(name: string): void { + this.#counts[name] = (this.#counts[name] ?? 0) + 1; + } + + // Capability fixtures, built fresh per attachment like a real host + // would. A fixture id maps to [global name, capability] so two fixtures + // (configV1/configV2) can grant the same name with different values. + #fixtures(names: string[]): Record { + const count = (name: string) => this.#count(name); + const available: Record [string, ReplCapability]> = { + weather: () => [ + "weather", + capability( + { + get: (city: string) => { + count("weather.get"); + return { city, temp: 21.5, asOf: new Date(1735732800000) }; + }, + flaky: () => { + count("weather.flaky"); + throw new Error(`boom ${this.#counts["weather.flaky"]}`); + }, + }, + { + description: "Weather lookups", + docs: { get: "get(city) → { city, temp, asOf }" }, + }, + ), + ], + sendEmail: () => [ + "sendEmail", + capability((to: string, subject: string) => { + count("sendEmail"); + return { queued: true, id: `msg-${this.#counts.sendEmail}`, to, subject }; + }), + ], + browser: () => ["browser", capability(new BrowserFixture(count))], + // Nested callable surface (children) beside plain data. + api: () => [ + "api", + capability({ + version: "2.0", + users: { + list: () => { + count("api.users.list"); + return ["ada", "grace"]; + }, + }, + }), + ], + // Unreflectable surface — properties materialize on access, like an + // RPC stub. The runner must treat every property as a method. + stub: () => [ + "stub", + capability( + new Proxy( + {}, + { + get: (_target, prop) => + prop === "ping" + ? () => { + count("stub.ping"); + return "pong"; + } + : undefined, + getPrototypeOf: () => null, + }, + ) as object, + ), + ], + // A method that returns a bare function — minted as a callable handle. + tools: () => [ + "tools", + capability({ + makeGreeter: (name: string) => { + count("tools.makeGreeter"); + return (greeting: string) => { + count("tools.greet"); + return `${greeting}, ${name}!`; + }; + }, + }), + ], + configV1: () => [ + "config", + capability({ apiUrl: "https://v1.example", retries: 1 }), + ], + configV2: () => [ + "config", + capability({ apiUrl: "https://v2.example", retries: 5 }), + ], + fs: () => ["fs", workspaceFs({ fs: this.#countingFs() })], + fetchBlocked: () => ["fetch", fetchCapability({ allow: ["allowed.example"] })], + fetchSelf: () => [ + "fetch", + fetchCapability({ + fetch: (url: string, init?: RequestInit) => { + count("gateway.fetch"); + return this.env.SELF.fetch(url, init); + }, + }), + ], + big: () => [ + "big", + capability({ + blob: (n: number) => { + count("big.blob"); + return "x".repeat(n); + }, + }), + ], + }; + const grants: Record = {}; + for (const name of names) { + const build = available[name]; + if (!build) throw new Error(`Unknown capability fixture: ${name}`); + const [globalName, grant] = build(); + grants[globalName] = grant; + } + return grants; + } + + // The workspace filesystem with live-call counting on every method, + // proving replay never re-fires filesystem effects. `this`-preserving + // delegation (private fields inside WorkspaceFilesystem). + #countingFs(): Workspace["fs"] { + const fs = this.#ws().fs; + const count = (name: string) => this.#count(name); + return new Proxy(fs, { + get(target, prop) { + const value = Reflect.get(target, prop, target) as unknown; + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + count(`fs.${String(prop)}`); + return (value as (...call: unknown[]) => unknown).apply(target, args); + }; + }, + }); + } + + counts(): Record { + return this.#counts; + } + + async replEvalWith( + fixtures: string[], + session: string, + code: string, + options?: ReplEvalOptions, + sessionOptions?: { maxEffectBytes?: number }, + ): Promise { + return this.#ws() + .repl(session, { + loader: this.env.LOADER, + capabilities: this.#fixtures(fixtures), + ...sessionOptions, + }) + .eval(code, options); + } + async replEval( session: string, code: string, @@ -70,10 +271,23 @@ export class ReplHostDO extends DurableObject { cellSeq, ); } + + // Rewrite recorded capability-call text (args live inside the call + // identity), simulating a log whose recorded args no longer match code. + corruptCapCallArgs(session: string, from: string, to: string): void { + this.ctx.storage.sql.exec( + "UPDATE repl_effects SET value = replace(value, ?, ?) WHERE session = ? AND kind = 'cap'", + from, + to, + session, + ); + } } export default { - fetch(): Response { - return new Response("repl test host", { status: 200 }); + // Doubles as the gateway target for fetchSelf: returns canned JSON so + // fetch-capability tests need no real network. + fetch(request: Request): Response { + return Response.json({ ok: true, path: new URL(request.url).pathname }); }, }; diff --git a/packages/computer/tests/repl.test.ts b/packages/computer/tests/repl.test.ts index f7886f15..817af103 100644 --- a/packages/computer/tests/repl.test.ts +++ b/packages/computer/tests/repl.test.ts @@ -75,11 +75,12 @@ describe("repl session eval", () => { "const dateStr = Date();", "const perf = performance.now();", "const bytes = Array.from(crypto.getRandomValues(new Uint8Array(8)));", + "const words = Array.from(crypto.getRandomValues(new Int32Array(4)));", "const derived = rolls.map((r) => Math.floor(r * 1000));", ].join("\n")); const before = await h.replEval( "main", - "return { rolls, stamp, id, iso, dateStr, perf, bytes, derived, whenMs: when.getTime() };", + "return { rolls, stamp, id, iso, dateStr, perf, bytes, words, derived, whenMs: when.getTime() };", ); expect(before.error).toBeUndefined(); @@ -87,7 +88,7 @@ describe("repl session eval", () => { const after = await h.replEval( "main", - "return { rolls, stamp, id, iso, dateStr, perf, bytes, derived, whenMs: when.getTime() };", + "return { rolls, stamp, id, iso, dateStr, perf, bytes, words, derived, whenMs: when.getTime() };", ); expect(after.error).toBeUndefined(); expect(after.value).toEqual(before.value); diff --git a/packages/computer/tests/wrangler.repl.jsonc b/packages/computer/tests/wrangler.repl.jsonc index 95c29d6c..369b7481 100644 --- a/packages/computer/tests/wrangler.repl.jsonc +++ b/packages/computer/tests/wrangler.repl.jsonc @@ -5,6 +5,7 @@ "compatibility_date": "2026-06-23", "compatibility_flags": ["nodejs_compat", "experimental"], "worker_loaders": [{ "binding": "LOADER" }], + "services": [{ "binding": "SELF", "service": "repl-tests" }], "durable_objects": { "bindings": [{ "name": "HOST", "class_name": "ReplHostDO" }] }, diff --git a/packages/computer/vitest.config.repl.ts b/packages/computer/vitest.config.repl.ts index d17e8928..cb03d631 100644 --- a/packages/computer/vitest.config.repl.ts +++ b/packages/computer/vitest.config.repl.ts @@ -10,7 +10,7 @@ export default defineConfig({ ], test: { globals: true, - include: ["tests/repl.test.ts"], + include: ["tests/repl.test.ts", "tests/repl-capabilities.test.ts"], testTimeout: 60_000, }, });