Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-rpc-modules.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
45 changes: 16 additions & 29 deletions packages/computer/src/backends/worker-javascript/module-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Optional host fields fail executions

When a host module returns an object with an undefined field, call passes that field to the isolate. assertRuntimeValue rejects it if user code returns the object, so the execution fails.

Learn more

Host module return values pass assertBridgeValues, which skips undefined object fields without removing them. Native RPC preserves those fields. The runner passes the returned value to assertResult, where assertRuntimeValue rejects undefined. The previous codec dropped those fields before the isolate received them. Native transport also changes undefined array arguments from null to undefined and preserves undefined request fields, changing what host functions receive.

Example: A host function returns { value: 1, optional: undefined }. export default () => getValue() previously completed with { value: 1 }; now result validation fails before the exit frame.

Recommended fix: Normalize host-module arguments and results to the documented JSON-compatible semantics before sending them across RPC: omit undefined object fields and convert undefined array elements to null. Keep the existing plain-value checks and byte limits on the normalized values.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
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;
}
`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ describe("WorkerJavaScriptBackend", () => {
attachOutput(readable: ReadableStream<Uint8Array>): Promise<void>;
},
) {
void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"]));
void host.call("fs.writeFile", ["/workspace/output.txt", "done"]);
return evaluateResult(host, 1);
},
};
Expand Down Expand Up @@ -792,9 +792,9 @@ describe("WorkerJavaScriptBackend", () => {
return {
async evaluate(
_input: unknown,
host: { call(name: string, args: string): Promise<string> },
host: { call(name: string, args: unknown[]): Promise<unknown> },
) {
await host.call("host/ws:test.run", JSON.stringify([]));
await host.call("host/ws:test.run", []);
},
};
},
Expand Down Expand Up @@ -843,9 +843,9 @@ describe("WorkerJavaScriptBackend", () => {
return {
evaluate(
_input: unknown,
host: { call(name: string, args: string): Promise<string> },
host: { call(name: string, args: unknown[]): Promise<unknown> },
) {
void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"]));
void host.call("fs.writeFile", ["/workspace/output.txt", "done"]);
return new Promise(() => undefined);
},
};
Expand Down Expand Up @@ -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: {
Expand All @@ -1130,9 +1130,9 @@ describe("WorkerJavaScriptBackend", () => {
return {
async evaluate(
_input: unknown,
host: { call(name: string, args: string): Promise<string> },
host: { call(name: string, args: unknown[]): Promise<unknown> },
) {
response = await host.call("host/ws:test.toString", JSON.stringify([]));
response = await host.call("host/ws:test.toString", []);
},
};
},
Expand All @@ -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?.();
Expand Down
54 changes: 47 additions & 7 deletions packages/computer/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,8 +17,9 @@ function bridge(limits: {
});
}

async function message(response: Promise<string>) {
return (JSON.parse(await response) as { error?: { message?: string } }).error?.message;
async function message(response: Promise<BridgeResponse>) {
const settled = await response;
return "error" in settled ? settled.error.message : undefined;
}

describe("WorkspaceRuntimeBridge cumulative limits", () => {
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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<string, unknown> = {};
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 });
Expand Down
Loading
Loading