From 2040d2124e2e2863e739f12efd3d59cb28c65087 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 11:42:32 +0100 Subject: [PATCH] computer: Pass isolate capability calls as native RPC values The Dynamic Worker already received a real RPC object, the runtime bridge, but every node:fs and host module call was JSON-encoded on top of it: arguments became a string with a custom codec for bytes, arrays, and objects, and results came back the same way. Bytes travelled as arrays of numbers, roughly four times their size against the capability byte limit, and both sides carried an encoder and a decoder. Arguments and results now cross as Workers RPC values. The isolate passes its argument list straight to host.call, and the bridge answers with { result } or a bounded { error } carrying code and path for node:fs. The bridge stays the single proxy for every call, so its controls are unchanged: cancellation, concurrent and total call counts, per-call deadlines with abort, and draining accepted calls before an execution ends. Byte budgets now measure the values themselves: UTF-8 bytes of strings and keys, raw bytes of byte arrays, and a fixed cost per scalar. The same walk rejects anything that is not plain data, including functions and RPC stubs that Workers RPC would otherwise carry into the host as live callbacks, and cycles. --- .changeset/native-rpc-modules.md | 5 + docs/17_isolate_javascript.md | 4 +- .../worker-javascript/module-graph.ts | 45 ++-- .../worker-javascript.test.ts | 18 +- packages/computer/src/runtime/bridge.test.ts | 54 +++- packages/computer/src/runtime/bridge.ts | 235 +++++++++--------- packages/computer/src/runtime/types.ts | 8 +- packages/computer/tests/script-runner.test.ts | 25 +- 8 files changed, 219 insertions(+), 175 deletions(-) create mode 100644 .changeset/native-rpc-modules.md diff --git a/.changeset/native-rpc-modules.md b/.changeset/native-rpc-modules.md new file mode 100644 index 00000000..c815a6cc --- /dev/null +++ b/.changeset/native-rpc-modules.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`WorkerJavaScriptBackend` passes `node:fs` and host module calls between the isolate and the Durable Object as real Workers RPC values instead of JSON text with a custom byte encoding. Byte arrays now count at their real size against `maxCapabilityBytes`, so a 900-byte write fits under a 1024-byte limit where it used to be rejected. Every call still goes through one host bridge that enforces call counts, concurrency, deadlines, and byte budgets, and it now rejects functions, RPC stubs, and cycles in a request before the host acts on it. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index b37af753..26856c69 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -82,7 +82,7 @@ Workspace parses the graph before loading the Worker, confines every durable pat The backend admits up to twenty-four executions at a time by default. A concurrent start past that ceiling fails with `EEXEC_BUSY` instead of creating an unbounded number of Dynamic Workers. Adjust `maxConcurrentExecutions` after measuring the Durable Object and Worker Loader limits for the deployment. -Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. +Each execution also bounds combined stdout and stderr output, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxStdioBytes`, `maxExecutionSubscribers`, `maxDirectoryEntries`, and `max*Capability*` options may be lowered for public workloads. Directory reads apply their limit in SQLite before materializing rows. Requests are checked inside the isolate before Workers RPC and again by the host. Every capability call goes through one host bridge that enforces these limits. Values cross as real Workers RPC values, measured as UTF-8 bytes for strings and raw bytes for byte arrays, and anything that is not plain data, such as a function, an RPC stub, or a cycle, is rejected before the host acts on it. Completed execution records remain available for replay for sixty minutes by default. The backend also keeps at most 100 completed records. Configure these bounds with `retentionMs` and `maxRetainedExecutions`. Completed records leave the in-memory active set immediately; replay reads them from SQLite. @@ -227,7 +227,7 @@ Each function receives the arguments the isolate passed, as an array of JSON-com | `access` | The backend's `"read"` or `"read-write"` access. Check it before any write. | | `resolvePath(path, { allowMissing })` | Confines a caller path to the backend root and rejects symlinks. | -The arguments come from caller code, so parse them before use. A function may return a value or a promise. The result must be JSON-compatible, and the bridge checks it at runtime: `undefined` becomes `null` and `undefined` object fields are dropped, as with `JSON.stringify`. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state. +The arguments come from caller code, so parse them before use. A function may return a value or a promise. Arguments and results cross the isolate boundary as real values through Workers RPC, not as encoded text. The result must be JSON-compatible plain data, and the bridge checks it at runtime; `undefined` becomes `null`. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state. Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. A module must export at least one function, and every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "ws:files"`. Importing a name the module does not export fails when the module graph links, before any code runs. diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index 2e94ca65..08de6ac8 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -342,44 +342,31 @@ function capabilitiesModule(maxCapabilityBytes: number) { } return call(namespace, method, args); } + // Arguments and results cross as real values through Workers RPC. + // The host bridge measures and limits them; this early check only + // spares an obviously oversized request the round trip. export async function call(namespace, method, args) { if (!host) throw new Error("Workspace capabilities are not installed"); - const request = JSON.stringify(args.map(encode)); - if (new TextEncoder().encode(request).byteLength > ${maxCapabilityBytes}) { + if (approximateBytes(args) > ${maxCapabilityBytes}) { throw new Error(${JSON.stringify(requestTooLargeMessage)}); } - const raw = await host.call(namespace + "." + method, request); - const payload = JSON.parse(String(raw)); + const payload = await host.call(namespace + "." + method, args); if (payload.error !== undefined) { - const detail = typeof payload.error === "string" ? { message: payload.error } : payload.error; - const error = new Error(detail.message); - if (detail.code !== undefined) error.code = detail.code; - if (detail.path !== undefined) error.path = detail.path; + const error = new Error(payload.error.message); + if (payload.error.code !== undefined) error.code = payload.error.code; + if (payload.error.path !== undefined) error.path = payload.error.path; throw error; } - return decode(payload.result); + return payload.result; } - function wrap(type, fields) { - return { __workspace_codec__: { version: 1, type, ...fields } }; - } - function encode(value) { - if (value instanceof Uint8Array) return wrap("bytes", { data: Array.from(value) }); - if (Array.isArray(value)) return wrap("array", { items: value.map(encode) }); - if (value && typeof value === "object") return wrap("object", { entries: Object.entries(value).map(([key, child]) => [key, encode(child)]) }); - return value; - } - function decode(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return value; - if (Object.keys(value).length !== 1 || !("__workspace_codec__" in value)) throw new Error("Invalid Workspace codec envelope"); - const codec = value.__workspace_codec__; - if (!codec || codec.version !== 1) throw new Error("Invalid Workspace codec envelope"); - if (codec.type === "bytes") { - if (!Array.isArray(codec.data) || !codec.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) throw new Error("Invalid Workspace byte value"); - return new Uint8Array(codec.data); + function approximateBytes(value) { + if (typeof value === "string") return value.length; + if (value instanceof Uint8Array) return value.byteLength; + if (Array.isArray(value)) return value.reduce((total, item) => total + approximateBytes(item), 8); + if (value && typeof value === "object") { + return Object.entries(value).reduce((total, [key, item]) => total + key.length + approximateBytes(item), 8); } - if (codec.type === "array" && Array.isArray(codec.items)) return codec.items.map(decode); - if (codec.type === "object" && Array.isArray(codec.entries)) return Object.fromEntries(codec.entries.map(([key, child]) => [key, decode(child)])); - throw new Error("Invalid Workspace codec envelope"); + return 8; } `; } diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index e75b00fb..cae6ef76 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -557,7 +557,7 @@ describe("WorkerJavaScriptBackend", () => { attachOutput(readable: ReadableStream): Promise; }, ) { - void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + void host.call("fs.writeFile", ["/workspace/output.txt", "done"]); return evaluateResult(host, 1); }, }; @@ -792,9 +792,9 @@ describe("WorkerJavaScriptBackend", () => { return { async evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - await host.call("host/ws:test.run", JSON.stringify([])); + await host.call("host/ws:test.run", []); }, }; }, @@ -843,9 +843,9 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); + void host.call("fs.writeFile", ["/workspace/output.txt", "done"]); return new Promise(() => undefined); }, }; @@ -1120,7 +1120,7 @@ describe("WorkerJavaScriptBackend", () => { initializeSchema(db, () => 0); const fs = new WorkspaceFilesystem(db); await fs.mkdir("/workspace", { recursive: true }); - let response = ""; + let response: unknown; const backend = new WorkerJavaScriptBackend({ modules: { "ws:test": { run: async () => null } }, loader: { @@ -1130,9 +1130,9 @@ describe("WorkerJavaScriptBackend", () => { return { async evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { call(name: string, args: unknown[]): Promise }, ) { - response = await host.call("host/ws:test.toString", JSON.stringify([])); + response = await host.call("host/ws:test.toString", []); }, }; }, @@ -1151,7 +1151,7 @@ describe("WorkerJavaScriptBackend", () => { for await (const _event of execution.events) { // Drain the run so the host call settles. } - expect(JSON.parse(response)).toMatchObject({ + expect(response).toMatchObject({ error: { message: expect.stringContaining("Unknown Workspace host module call") }, }); await handle.close?.(); diff --git a/packages/computer/src/runtime/bridge.test.ts b/packages/computer/src/runtime/bridge.test.ts index 94d16a4f..f2acc11a 100644 --- a/packages/computer/src/runtime/bridge.test.ts +++ b/packages/computer/src/runtime/bridge.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { WorkspaceRuntimeBridge } from "./bridge.js"; +import { type BridgeResponse, WorkspaceRuntimeBridge } from "./bridge.js"; import type { WorkspaceRuntimeCapability } from "./capability.js"; const encoder = new TextEncoder(); -const args = JSON.stringify(["value"]); +const args = ["value"]; function bridge(limits: { maxCalls?: number; @@ -17,8 +17,9 @@ function bridge(limits: { }); } -async function message(response: Promise) { - return (JSON.parse(await response) as { error?: { message?: string } }).error?.message; +async function message(response: Promise) { + const settled = await response; + return "error" in settled ? settled.error.message : undefined; } describe("WorkspaceRuntimeBridge cumulative limits", () => { @@ -32,7 +33,8 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { }); it("accepts requests at the cumulative byte boundary and rejects the next request", async () => { - const bytes = encoder.encode(args).byteLength; + // ["value"]: 8 for the array plus 5 UTF-8 bytes for the string. + const bytes = 8 + encoder.encode("value").byteLength; const target = bridge({ maxTotalRequestBytes: bytes * 2 }); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); @@ -42,8 +44,8 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { }); it("accepts responses at the cumulative byte boundary and rejects the next response", async () => { - const sample = await bridge({}).call("host/ws:test.run", args); - const bytes = encoder.encode(sample).byteLength; + // The host function returns "ok": 2 UTF-8 bytes. + const bytes = encoder.encode("ok").byteLength; const target = bridge({ maxTotalResponseBytes: bytes * 2 }); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); await expect(message(target.call("host/ws:test.run", args))).resolves.toBeUndefined(); @@ -53,6 +55,44 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { }); }); +describe("WorkspaceRuntimeBridge values", () => { + function echoBridge(maxPayloadBytes?: number) { + return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { + ...(maxPayloadBytes === undefined ? {} : { maxPayloadBytes }), + hostModules: new Map([["ws:test", { run: async (values) => values[0] ?? null }]]), + }); + } + + it("passes plain values through without encoding them", async () => { + await expect( + echoBridge().call("host/ws:test.run", [{ nested: [1, "two", null, { three: true }] }]), + ).resolves.toEqual({ result: { nested: [1, "two", null, { three: true }] } }); + }); + + it.each([ + ["a function", () => 1], + ["a class instance", new Date(0)], + ])("rejects %s in a request before it reaches the host", async (_label, value) => { + await expect(message(echoBridge().call("host/ws:test.run", [value]))).resolves.toContain( + "plain data", + ); + }); + + it("rejects a cyclic request", async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + await expect(message(echoBridge().call("host/ws:test.run", [cyclic]))).resolves.toContain( + "acyclic", + ); + }); + + it("rejects a request over the payload limit by its UTF-8 size", async () => { + await expect( + message(echoBridge(256).call("host/ws:test.run", ["é".repeat(200)])), + ).resolves.toContain("request exceeds 256 bytes"); + }); +}); + describe("WorkspaceRuntimeBridge assertResult", () => { function resultBridge(maxResultBytes?: number) { return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { maxResultBytes }); diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts index cfe4a6e8..764ec7c1 100644 --- a/packages/computer/src/runtime/bridge.ts +++ b/packages/computer/src/runtime/bridge.ts @@ -1,5 +1,6 @@ import { RpcTarget } from "cloudflare:workers"; +import { utf8Prefix } from "../text-truncation.js"; import { assertRuntimeValue, type WorkspaceRuntimeCapability } from "./capability.js"; import type { WorkspaceModuleCallContext, WorkspaceModuleFunctions } from "./types.js"; @@ -14,7 +15,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { readonly #maxTotalResponseBytes: number; readonly #maxResultBytes: number; readonly #onAttachOutput?: (readable: ReadableStream) => Promise; - readonly #inFlight = new Set>(); + readonly #inFlight = new Set>(); readonly #abortControllers = new Set(); #cancelled = false; #callTimedOut = false; @@ -71,13 +72,22 @@ export class WorkspaceRuntimeBridge extends RpcTarget { } } - call(name: string, argsJson: string): Promise { - const requestBytes = new TextEncoder().encode(argsJson).byteLength; + // The one entry point for isolate code. Arguments and results cross + // as real values through Workers RPC; this method is the proxy that + // keeps the limits on them, so untrusted code cannot overload the + // Durable Object with calls, concurrency, time, or bytes. + call(name: string, args: unknown[]): Promise { const reject = (message: string) => - Promise.resolve(encodeBoundedError(new Error(message), this.#maxPayloadBytes)); + Promise.resolve(boundedError(new Error(message), this.#maxPayloadBytes)); if (this.#cancelled) return reject("Workspace execution is being cancelled."); - if (requestBytes > this.#maxPayloadBytes) { - return reject(`Workspace capability request exceeds ${this.#maxPayloadBytes} bytes.`); + if (typeof name !== "string" || !Array.isArray(args)) { + return reject("Workspace capability calls take a name and an argument list."); + } + let requestBytes: number; + try { + requestBytes = measureValue(args, this.#maxPayloadBytes, "request"); + } catch (error) { + return Promise.resolve(boundedError(error, this.#maxPayloadBytes)); } if (this.#inFlight.size >= this.#maxConcurrentCalls) { return reject( @@ -97,9 +107,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { const abort = new AbortController(); this.#abortControllers.add(abort); const deadline = Date.now() + this.#maxCallDurationMs; - const operation = encodeCall(async () => { - const encodedArgs = JSON.parse(argsJson) as unknown[]; - const args = encodedArgs.map(decodeBridgeValue); + const operation = respond(async () => { if (name.startsWith("host/")) { return this.#callHostModule(name, args, { signal: abort.signal, @@ -186,9 +194,10 @@ export class WorkspaceRuntimeBridge extends RpcTarget { this.#abortControllers.delete(abort); }); return call.then((response) => { - const bytes = new TextEncoder().encode(response).byteLength; + if (!("result" in response)) return response; + const bytes = response.bytes; if (this.#responseBytes + bytes > this.#maxTotalResponseBytes) { - return encodeBoundedError( + return boundedError( new Error( `Workspace execution capability responses exceed ${this.#maxTotalResponseBytes} bytes.`, ), @@ -196,7 +205,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { ); } this.#responseBytes += bytes; - return response; + return { result: response.result }; }); } @@ -256,7 +265,7 @@ function assertBridgeValues( if (prototype !== Object.prototype && prototype !== null) { throw new Error("Host module values must contain only plain objects."); } - // An undefined field is absent, as in JSON. encodeBridgeValue drops it. + // An undefined field is allowed, as in JSON, and arrives as undefined. for (const item of Object.values(value as Record)) { if (item !== undefined) visit(item); } @@ -270,70 +279,93 @@ function decodeBytes(value: unknown): string | Uint8Array { return value instanceof Uint8Array ? value : String(value); } -function encodeBridgeValue(value: unknown): unknown { - const wrap = (type: string, fields: Record) => ({ - __workspace_codec__: { version: 1, type, ...fields }, - }); - if (value instanceof Uint8Array) return wrap("bytes", { data: Array.from(value) }); - if (Array.isArray(value)) return wrap("array", { items: value.map(encodeBridgeValue) }); - if (value && typeof value === "object") { - return wrap("object", { - entries: Object.entries(value) - .filter(([, child]) => child !== undefined) - .map(([key, child]) => [key, encodeBridgeValue(child)]), - }); - } - return value; -} +/** What the bridge sends back for one call: a value, or a bounded error. */ +export type BridgeResponse = + | { readonly result: unknown } + | { + readonly error: { readonly message: string; readonly code?: string; readonly path?: string }; + }; -function decodeBridgeValue(value: unknown): unknown { - if (!value || typeof value !== "object" || Array.isArray(value)) return value; - const record = value as Record; - if (Object.keys(record).length !== 1 || !("__workspace_codec__" in record)) { - throw new Error("Invalid Workspace codec envelope."); - } - const codec = record.__workspace_codec__ as Record | null; - if (codec?.version !== 1) throw new Error("Invalid Workspace codec envelope."); - if (codec.type === "bytes") { - if (!isByteArray(codec.data)) throw new Error("Invalid Workspace byte value."); - return new Uint8Array(codec.data); - } - if (codec.type === "array" && Array.isArray(codec.items)) { - return codec.items.map(decodeBridgeValue); - } - if (codec.type === "object" && Array.isArray(codec.entries)) { - return Object.fromEntries( - codec.entries.map((entry) => { - if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") { - throw new Error("Invalid Workspace object entry."); - } - return [entry[0], decodeBridgeValue(entry[1])]; - }), - ); - } - throw new Error("Invalid Workspace codec envelope."); -} +// A response before the per-execution budget check, carrying its size. +type MeasuredResponse = + | { result: unknown; bytes: number } + | Extract; + +const encoder = new TextEncoder(); +const MAX_RESPONSE_VALUES = 4096; -function isByteArray(value: unknown): value is number[] { - return ( - Array.isArray(value) && - value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) - ); +// Measure plain data the way it costs the Durable Object: UTF-8 bytes +// of strings and keys, raw bytes of byte arrays, and a small fixed cost +// per scalar. Anything that is not plain data is rejected, including +// functions and RPC stubs, which Workers RPC would otherwise carry into +// the host as live callbacks, and cycles. +function measureValue(value: unknown, maxBytes: number, kind: "request" | "response"): number { + let bytes = 0; + let values = 0; + const seen = new Set(); + const add = (count: number) => { + bytes += count; + if (bytes > maxBytes) { + throw new Error(`Workspace capability ${kind} exceeds ${maxBytes} bytes.`); + } + }; + const visit = (item: unknown): void => { + values += 1; + if (kind === "response" && values > MAX_RESPONSE_VALUES) { + throw new Error("Workspace capability response has too many values."); + } + if ( + item === null || + item === undefined || + typeof item === "boolean" || + typeof item === "number" + ) { + add(8); + return; + } + if (typeof item === "string") { + add(encoder.encode(item).byteLength); + return; + } + if (item instanceof Uint8Array) { + add(item.byteLength); + return; + } + if (typeof item !== "object") { + throw new Error(`Workspace capability ${kind} values must be plain data.`); + } + if (seen.has(item)) throw new Error(`Workspace capability ${kind} values must be acyclic.`); + seen.add(item); + if (Array.isArray(item)) { + add(8); + for (const child of item) visit(child); + } else { + const prototype = Object.getPrototypeOf(item); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`Workspace capability ${kind} values must be plain data.`); + } + for (const [key, child] of Object.entries(item)) { + add(encoder.encode(key).byteLength); + visit(child); + } + } + seen.delete(item); + }; + visit(value); + return bytes; } function withDeadline( - call: Promise, + call: Promise, timeoutMs: number, maxPayloadBytes: number, onTimeout: () => void, -): Promise { +): Promise { let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { + const timeout = new Promise((resolve) => { timer = setTimeout(() => { onTimeout(); - resolve( - encodeBoundedError(new Error("Workspace capability call timed out."), maxPayloadBytes), - ); + resolve(boundedError(new Error("Workspace capability call timed out."), maxPayloadBytes)); }, timeoutMs); }); return Promise.race([call, timeout]).finally(() => { @@ -341,75 +373,32 @@ function withDeadline( }); } -async function encodeCall(run: () => Promise, maxPayloadBytes: number) { +async function respond( + run: () => Promise, + maxPayloadBytes: number, +): Promise { try { const result = await run(); - assertResponseWithin(result, maxPayloadBytes); - const encoded = JSON.stringify({ result: encodeBridgeValue(result) }); - if (new TextEncoder().encode(encoded).byteLength > maxPayloadBytes) { - throw new Error(`Workspace capability response exceeds ${maxPayloadBytes} bytes.`); - } - return encoded; + return { result, bytes: measureValue(result, maxPayloadBytes, "response") }; } catch (error) { - return encodeBoundedError(error, maxPayloadBytes); + return boundedError(error, maxPayloadBytes); } } -function assertResponseWithin(value: unknown, maxBytes: number) { - let bytes = 0; - let nodes = 0; - const visit = (item: unknown): void => { - nodes += 1; - if (nodes > 4096) throw new Error("Workspace capability response has too many values."); - if (typeof item === "string") bytes += item.length * 3; - else if (item instanceof Uint8Array) bytes += item.byteLength * 4; - else if (typeof item === "number" || typeof item === "boolean" || item === null) bytes += 16; - else if (Array.isArray(item)) for (const child of item) visit(child); - else if (item && typeof item === "object") { - for (const [key, child] of Object.entries(item)) { - bytes += key.length * 3; - visit(child); - } - } - if (bytes > maxBytes) { - throw new Error(`Workspace capability response exceeds ${maxBytes} bytes.`); - } - }; - visit(value); -} - -function encodeBoundedError(error: unknown, maxPayloadBytes: number) { +// An error the isolate can rebuild, with its message cut to fit the +// payload limit. `code` and `path` carry node:fs error details. +function boundedError(error: unknown, maxPayloadBytes: number) { const value = error as { code?: unknown; path?: unknown }; const message = error instanceof Error ? error.message : String(error); - const detailed = JSON.stringify({ + return { error: { - message, + message: truncateText(message, Math.max(0, maxPayloadBytes - 64)), ...(typeof value?.code === "string" ? { code: value.code } : {}), ...(typeof value?.path === "string" ? { path: value.path } : {}), }, - }); - const encoder = new TextEncoder(); - if (encoder.encode(detailed).byteLength <= maxPayloadBytes) return detailed; - - let budget = Math.max(0, maxPayloadBytes - 40); - while (budget >= 0) { - const bounded = JSON.stringify({ error: { message: truncateUtf8(message, budget) } }); - if (encoder.encode(bounded).byteLength <= maxPayloadBytes) return bounded; - budget -= 1; - } - return JSON.stringify({ error: { message: "Capability call failed" } }); + }; } -function truncateUtf8(value: string, maxBytes: number) { - const bytes = new TextEncoder().encode(value); - if (bytes.byteLength <= maxBytes) return value; - let prefix = bytes.slice(0, maxBytes); - while (prefix.byteLength > 0) { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(prefix); - } catch { - prefix = prefix.slice(0, -1); - } - } - return ""; +function truncateText(value: string, maxBytes: number) { + return utf8Prefix(value, maxBytes).text; } diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 84bebae0..2183e3eb 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -28,10 +28,10 @@ export interface WorkspaceModuleCallContext { * * `args` holds the arguments the isolate passed, decoded from the wire. * They come from untrusted code, so parse them before use. The function - * may return a value or a promise of one. The result must be - * JSON-compatible: the bridge checks it at runtime, treats `undefined` - * as `null`, and drops `undefined` object fields, the way - * `JSON.stringify` does. + * may return a value or a promise of one. Arguments and results cross + * the isolate boundary as real values through Workers RPC, not as + * encoded text. The result must be JSON-compatible plain data: the + * bridge checks it at runtime and treats `undefined` as `null`. */ export type WorkspaceModuleFunction = ( args: readonly WorkspaceRuntimeValue[], diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index b017a34f..cdda1a21 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -100,7 +100,30 @@ describe("WorkspaceRuntime", () => { }); }); - it("round-trips bytes and marker-shaped plain objects without codec collisions", async () => { + it("moves bytes through node:fs without inflating them", async () => { + // 900 bytes fits under this fixture's 1024-byte capability limit as + // raw bytes. Encoded as JSON numbers it would be about four times + // larger and rejected. + const response = await runtime({ + source: ` + import fs from "node:fs/promises"; + export default async () => { + await fs.writeFile("/workspace/blob.bin", new Uint8Array(900).fill(255)); + const back = await fs.readFile("/workspace/blob.bin"); + return { isBytes: back instanceof Uint8Array, length: back.byteLength, last: back[899] }; + }; + `, + cwd: "/workspace", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text).result, text).toMatchObject({ + status: "completed", + value: { isBytes: true, length: 900, last: 255 }, + }); + }); + + it("round-trips bytes, and plain objects shaped like the old codec, unchanged", async () => { const response = await runtime({ source: ` import fs from "node:fs/promises";