From 290fb24c633adc8a6a93c14f3df2171bc2ab5fe4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:21:42 +0000 Subject: [PATCH 1/9] computer: pin the loader-boundary byte-stream transfer contract Add an end-to-end probe establishing how byte streams cross the Worker Loader RPC boundary, the primitive the live stdio path depends on. A stream transfers natively only when it is passed as a call argument: the isolate hands a ReadableStream to a bridge method and the host drains it live, and the host hands stdin to the isolate as an evaluate argument. Returning a stream from a bridge method does not transfer it; the receiver gets an RPC stub whose getReader and getWriter are absent. This fixes the direction the stdio design must take: the isolate creates the stdout and stderr transforms and passes their readable ends outward, rather than receiving writable ends from a host openStdio call. --- .../computer/tests/script-runner-worker.ts | 98 +++++++++++++++++++ packages/computer/tests/script-runner.test.ts | 11 +++ 2 files changed, 109 insertions(+) diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 6df9cc11..65a5f89c 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -140,6 +140,39 @@ class ModuleProbeBridge extends RpcTarget { } } +async function drainToString(readable: ReadableStream): Promise { + const reader = readable.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const next = await reader.read(); + if (next.done) break; + chunks.push(next.value); + } + reader.releaseLock(); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +class StdioProbeBridge extends RpcTarget { + #sinkResult = ""; + + // Direction C (stdout path): worker passes a ReadableStream as an + // argument; host drains it. + async sink(readable: ReadableStream): Promise { + this.#sinkResult = await drainToString(readable); + } + + sinkResult(): string { + return this.#sinkResult; + } +} + export default class extends WorkerEntrypoint { override async fetch(request: Request) { const url = new URL(request.url); @@ -203,6 +236,71 @@ export default class extends WorkerEntrypoint { (worker as unknown as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); } } + if (url.pathname === "/stdio-probe") { + const worker = this.env.LOADER.load({ + compatibilityDate: "2026-06-17", + compatibilityFlags: ["nodejs_compat"], + mainModule: "runner.js", + modules: { + "runner.js": ` + import { WorkerEntrypoint } from "cloudflare:workers"; + export default class extends WorkerEntrypoint { + async evaluate(bridge, stdin) { + const results = {}; + try { + results.stdin = await (async () => { + const reader = stdin.getReader(); + const parts = []; + while (true) { + const next = await reader.read(); + if (next.done) break; + parts.push(new TextDecoder().decode(next.value)); + } + return parts.join(""); + })(); + } catch (error) { + results.stdin = "ERR:" + (error instanceof Error ? error.message : String(error)); + } + try { + const transform = new IdentityTransformStream(); + const writer = transform.writable.getWriter(); + const done = bridge.sink(transform.readable); + await writer.write(new TextEncoder().encode("from-isolate")); + await writer.close(); + await done; + results.sink = "ok"; + } catch (error) { + results.sink = "ERR:" + (error instanceof Error ? error.message : String(error)); + } + return results; + } + } + `, + }, + globalOutbound: null, + }); + const entrypoint = worker.getEntrypoint() as unknown as { + evaluate( + bridge: StdioProbeBridge, + stdin: ReadableStream, + ): Promise>; + [Symbol.dispose]?: () => void; + }; + const bridge = new StdioProbeBridge(); + const stdinTransform = new IdentityTransformStream(); + void (async () => { + const writer = stdinTransform.writable.getWriter(); + await writer.write(new TextEncoder().encode("from-host")); + await writer.close(); + })(); + try { + const results = await entrypoint.evaluate(bridge, stdinTransform.readable); + return Response.json({ ...results, sinkResult: bridge.sinkResult() }); + } finally { + entrypoint[Symbol.dispose]?.(); + (worker as unknown as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.(); + } + } if (url.pathname === "/runtime") { return Response.json( await stub.runRuntime( diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 48c02084..1a67d917 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -38,6 +38,17 @@ describe("WorkspaceRuntime", () => { expect(text).toBe("host:/workspace/probe.txt|relative|trusted"); }); + it("transfers byte streams across the loader boundary", async () => { + const response = await SELF.fetch("https://example.test/stdio-probe"); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(JSON.parse(text)).toEqual({ + stdin: "from-host", + sink: "ok", + sinkResult: "from-isolate", + }); + }); + it("executes an ES module with configured and trusted modules", async () => { const response = await runtime({ source: ` From 440cdbb29d901e28438ddcc5196d605a029a10df Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:17:51 +0000 Subject: [PATCH 2/9] computer: add the JavaScript runtime frame codec Introduce the host-side decoder for the framed execution stream the JavaScript backend will produce. Frames are newline-delimited JSON objects; stdout and stderr payloads are base64-encoded so arbitrary bytes round-trip losslessly, while result and exit carry their JSON values directly. The id and seq fields stay host-assigned as events are appended. The decoder buffers across chunk boundaries, emits a trailing frame that arrives without a final newline, skips blank lines, and errors the stream on a malformed frame. --- .../backends/worker-javascript/frames.test.ts | 101 ++++++++++++++++++ .../src/backends/worker-javascript/frames.ts | 74 +++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 packages/computer/src/backends/worker-javascript/frames.test.ts create mode 100644 packages/computer/src/backends/worker-javascript/frames.ts diff --git a/packages/computer/src/backends/worker-javascript/frames.test.ts b/packages/computer/src/backends/worker-javascript/frames.test.ts new file mode 100644 index 00000000..c065f481 --- /dev/null +++ b/packages/computer/src/backends/worker-javascript/frames.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { decodeRuntimeFrames, parseRuntimeFrame, type RuntimeFrame } from "./frames.js"; + +function streamOf(...chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +async function collect(stream: ReadableStream): Promise { + const frames: RuntimeFrame[] = []; + const reader = stream.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) break; + frames.push(next.value); + } + return frames; +} + +const b64 = (text: string) => btoa(text); + +describe("parseRuntimeFrame", () => { + it("decodes a base64 stdout frame into raw bytes", () => { + const frame = parseRuntimeFrame(`{"name":"stdout","b64":"${b64("hello")}"}`); + expect(frame).toEqual({ name: "stdout", value: new TextEncoder().encode("hello") }); + }); + + it("decodes a base64 stderr frame into raw bytes", () => { + const frame = parseRuntimeFrame(`{"name":"stderr","b64":"${b64("oops")}"}`); + expect(frame).toEqual({ name: "stderr", value: new TextEncoder().encode("oops") }); + }); + + it("decodes a result frame carrying a structured value", () => { + const frame = parseRuntimeFrame(`{"name":"result","value":{"a":[1,2,null]}}`); + expect(frame).toEqual({ name: "result", value: { a: [1, 2, null] } }); + }); + + it("decodes an exit frame carrying an integer", () => { + expect(parseRuntimeFrame(`{"name":"exit","value":0}`)).toEqual({ name: "exit", value: 0 }); + expect(parseRuntimeFrame(`{"name":"exit","value":130}`)).toEqual({ name: "exit", value: 130 }); + }); + + it("rejects invalid JSON", () => { + expect(() => parseRuntimeFrame("not json")).toThrow(); + }); + + it("rejects an unknown frame name", () => { + expect(() => parseRuntimeFrame(`{"name":"other","value":1}`)).toThrow(); + }); + + it("rejects a malformed stdout frame missing its payload", () => { + expect(() => parseRuntimeFrame(`{"name":"stdout"}`)).toThrow(); + }); + + it("rejects an exit frame whose value is not an integer", () => { + expect(() => parseRuntimeFrame(`{"name":"exit","value":"x"}`)).toThrow(); + }); +}); + +describe("decodeRuntimeFrames", () => { + it("decodes newline-delimited frames arriving in one chunk", async () => { + const frames = await collect( + decodeRuntimeFrames( + streamOf(`{"name":"stdout","b64":"${b64("hi")}"}\n{"name":"exit","value":0}\n`), + ), + ); + expect(frames).toEqual([ + { name: "stdout", value: new TextEncoder().encode("hi") }, + { name: "exit", value: 0 }, + ]); + }); + + it("reassembles a frame split across chunk boundaries", async () => { + const line = `{"name":"stdout","b64":"${b64("split")}"}\n`; + const mid = Math.floor(line.length / 2); + const frames = await collect( + decodeRuntimeFrames(streamOf(line.slice(0, mid), line.slice(mid))), + ); + expect(frames).toEqual([{ name: "stdout", value: new TextEncoder().encode("split") }]); + }); + + it("emits a trailing frame that arrives without a final newline", async () => { + const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":1}`))); + expect(frames).toEqual([{ name: "exit", value: 1 }]); + }); + + it("skips blank lines between frames", async () => { + const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":0}\n\n`))); + expect(frames).toEqual([{ name: "exit", value: 0 }]); + }); + + it("errors the stream on a malformed frame", async () => { + await expect(collect(decodeRuntimeFrames(streamOf(`garbage\n`)))).rejects.toThrow(); + }); +}); diff --git a/packages/computer/src/backends/worker-javascript/frames.ts b/packages/computer/src/backends/worker-javascript/frames.ts new file mode 100644 index 00000000..d924a6f5 --- /dev/null +++ b/packages/computer/src/backends/worker-javascript/frames.ts @@ -0,0 +1,74 @@ +import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; + +export type RuntimeFrame = + | { name: "stdout"; value: Uint8Array } + | { name: "stderr"; value: Uint8Array } + | { name: "result"; value: WorkspaceRuntimeValue } + | { name: "exit"; value: number }; + +export function parseRuntimeFrame(line: string): RuntimeFrame { + let record: Record; + try { + record = JSON.parse(line) as Record; + } catch { + throw new Error("WorkerJavaScriptBackend received an invalid execution frame"); + } + const name = record.name; + if (name === "stdout" || name === "stderr") { + if (typeof record.b64 !== "string") { + throw new Error("WorkerJavaScriptBackend received a malformed output frame"); + } + return { name, value: decodeBase64(record.b64) }; + } + if (name === "result") { + return { name, value: record.value as WorkspaceRuntimeValue }; + } + if (name === "exit") { + if (!Number.isSafeInteger(record.value)) { + throw new Error("WorkerJavaScriptBackend received a malformed exit frame"); + } + return { name, value: record.value as number }; + } + throw new Error("WorkerJavaScriptBackend received an unknown execution frame"); +} + +export function decodeRuntimeFrames( + source: ReadableStream, +): ReadableStream { + const decoder = new TextDecoder(); + let buffer = ""; + const drain = (controller: TransformStreamDefaultController, final: boolean) => { + let nl = buffer.indexOf("\n"); + while (nl !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + if (line.length > 0) controller.enqueue(parseRuntimeFrame(line)); + nl = buffer.indexOf("\n"); + } + if (final && buffer.length > 0) { + controller.enqueue(parseRuntimeFrame(buffer)); + buffer = ""; + } + }; + return source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + drain(controller, false); + }, + flush(controller) { + buffer += decoder.decode(); + drain(controller, true); + }, + }), + ); +} + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} From 26639af55f87af0825fef203eda5a7352ffc3d53 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:29:13 +0000 Subject: [PATCH 3/9] computer: validate execution results through the runtime bridge Add assertResult to WorkspaceRuntimeBridge so the runner can validate a result before framing it. The value crosses as an RPC argument under structured clone, preserving fidelity, so a Date or other non-plain value is rejected at the boundary rather than silently coerced once the result is serialized to JSON in a frame. It reuses the existing assertRuntimeValue check and the result byte ceiling, which the backend now threads into the bridge. --- .../worker-javascript/worker-javascript.ts | 1 + packages/computer/src/runtime/bridge.test.ts | 22 +++++++++++++++++++ packages/computer/src/runtime/bridge.ts | 17 +++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index f0bd31a7..570a9f38 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -396,6 +396,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { maxCalls: this.#options.maxCapabilityCalls, maxTotalRequestBytes: this.#options.maxCapabilityRequestBytes, maxTotalResponseBytes: this.#options.maxCapabilityResponseBytes, + maxResultBytes: this.#options.maxResultBytes, }); record.bridge = bridge; record.control = startJavaScriptExecution({ diff --git a/packages/computer/src/runtime/bridge.test.ts b/packages/computer/src/runtime/bridge.test.ts index 202aa793..18f330a0 100644 --- a/packages/computer/src/runtime/bridge.test.ts +++ b/packages/computer/src/runtime/bridge.test.ts @@ -58,3 +58,25 @@ describe("WorkspaceRuntimeBridge cumulative limits", () => { ); }); }); + +describe("WorkspaceRuntimeBridge assertResult", () => { + function resultBridge(maxResultBytes?: number) { + return new WorkspaceRuntimeBridge({} as WorkspaceRuntimeCapability, { maxResultBytes }); + } + + it("accepts a JSON-compatible value", async () => { + await expect( + resultBridge().assertResult({ a: [1, 2, null], b: "ok" }), + ).resolves.toBeUndefined(); + }); + + it("rejects a value that is not JSON-compatible", async () => { + await expect(resultBridge().assertResult(new Date())).rejects.toThrow(/plain objects/); + }); + + it("rejects a value that exceeds the result byte ceiling", async () => { + await expect(resultBridge(8).assertResult("x".repeat(64))).rejects.toThrow( + /result exceeds 8 bytes/, + ); + }); +}); diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts index 60cfee49..c634cc71 100644 --- a/packages/computer/src/runtime/bridge.ts +++ b/packages/computer/src/runtime/bridge.ts @@ -2,7 +2,7 @@ import { RpcTarget } from "cloudflare:workers"; import type { ArtifactClient } from "../artifacts/index.js"; import type { GitClient } from "../git/index.js"; -import type { WorkspaceRuntimeCapability } from "./capability.js"; +import { assertRuntimeValue, type WorkspaceRuntimeCapability } from "./capability.js"; import type { WorkspaceTrustedModule } from "./types.js"; export class WorkspaceRuntimeBridge extends RpcTarget { @@ -18,6 +18,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { readonly #maxCalls: number; readonly #maxTotalRequestBytes: number; readonly #maxTotalResponseBytes: number; + readonly #maxResultBytes: number; readonly #inFlight = new Set>(); readonly #abortControllers = new Set(); #cancelled = false; @@ -40,6 +41,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { maxCalls?: number; maxTotalRequestBytes?: number; maxTotalResponseBytes?: number; + maxResultBytes?: number; } = {}, ) { super(); @@ -55,6 +57,19 @@ export class WorkspaceRuntimeBridge extends RpcTarget { this.#maxCalls = integrations.maxCalls ?? 256; this.#maxTotalRequestBytes = integrations.maxTotalRequestBytes ?? 8 * 1024 * 1024; this.#maxTotalResponseBytes = integrations.maxTotalResponseBytes ?? 8 * 1024 * 1024; + this.#maxResultBytes = integrations.maxResultBytes ?? 1024 * 1024; + } + + // Validate an execution result before the runner frames it as JSON. + // The value crosses as an RPC argument (structured clone, full + // fidelity), so a Date or other non-plain value is rejected here + // rather than silently coerced by the JSON framing downstream. + async assertResult(value: unknown): Promise { + assertRuntimeValue(value); + const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength; + if (bytes > this.#maxResultBytes) { + throw new Error(`Workspace runtime result exceeds ${this.#maxResultBytes} bytes.`); + } } call(name: string, argsJson: string): Promise { From a398bd84c50d2476d76aad6fcae5b6a99eee5f35 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:46:50 +0000 Subject: [PATCH 4/9] computer: carry JavaScript execution output over a framed stream Replace the runner's {result, logs, error} return value with a single framed byte stream, the shape the container transport already uses. The runner emits its captured stdout and stderr, then a result frame and a zero exit on success, or a truncated stderr frame and exit 1 on failure. The host drains the stream, decodes the frames, and drives the existing append and finish machinery, assigning id and seq as events land. Result validation moves to the runner, which calls the bridge's assertResult before framing so a non-plain value like a Date is rejected at the boundary rather than silently coerced by the JSON framing. The host disposes the Dynamic Worker only after the stream is fully drained, mirroring how the shell backend holds its worker for the event stream's lifetime. --- .../worker-javascript.test.ts | 51 +++- .../worker-javascript/worker-javascript.ts | 217 ++++++++++-------- 2 files changed, 167 insertions(+), 101 deletions(-) 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 b16e95ee..3b0c2870 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -21,6 +21,31 @@ function throwingLoader(message: string) { }; } +// Frame a successful result the way the real runner does: validate +// through the bridge, then emit a result frame and a zero exit. +async function resultStream( + host: { assertResult(value: unknown): Promise }, + value: unknown, +): Promise> { + const frames: string[] = []; + try { + await host.assertResult(value); + frames.push(JSON.stringify({ name: "result", value })); + frames.push(JSON.stringify({ name: "exit", value: 0 })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + frames.push(JSON.stringify({ name: "stderr", b64: btoa(`${message}\n`) })); + frames.push(JSON.stringify({ name: "exit", value: 1 })); + } + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(encoder.encode(`${frame}\n`)); + controller.close(); + }, + }); +} + describe("WorkerJavaScriptBackend", () => { it("requires a host event-lifetime hook", async () => { const backend = new ProductionWorkerJavaScriptBackend({ loader: throwingLoader("unused") }); @@ -159,7 +184,10 @@ describe("WorkerJavaScriptBackend", () => { it("enforces finite input and result byte ceilings", async () => { const load = vi.fn(() => ({ getEntrypoint() { - return { evaluate: async () => ({ result: "result-too-large" }) }; + return { + evaluate: (_input: unknown, host: { assertResult(value: unknown): Promise }) => + resultStream(host, "result-too-large"), + }; }, })); const workspace = new Workspace({ @@ -389,7 +417,12 @@ describe("WorkerJavaScriptBackend", () => { load() { return { getEntrypoint() { - return { evaluate: () => evaluation }; + return { + evaluate: ( + _input: unknown, + host: { assertResult(value: unknown): Promise }, + ) => evaluation.then((outcome) => resultStream(host, outcome.result)), + }; }, }; }, @@ -433,10 +466,13 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + host: { + call(name: string, args: string): Promise; + assertResult(value: unknown): Promise; + }, ) { void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); - return Promise.resolve({ result: 1 }); + return resultStream(host, 1); }, }; }, @@ -601,7 +637,12 @@ describe("WorkerJavaScriptBackend", () => { load() { return { getEntrypoint() { - return { evaluate: () => evaluation }; + return { + evaluate: ( + _input: unknown, + bridge: { assertResult(value: unknown): Promise }, + ) => evaluation.then((outcome) => resultStream(bridge, outcome.result)), + }; }, }; }, diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 570a9f38..6fa35223 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -12,6 +12,7 @@ import type { WorkspaceRuntimeValue, WorkspaceTrustedModule, } from "../../runtime/types.js"; +import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; import { buildModuleGraph } from "./module-graph.js"; export interface WorkerJavaScriptBackendOptions { @@ -97,17 +98,12 @@ interface WorkspaceExecutionContext { stdin: Uint8Array; } -interface RuntimeLogEntry { - stream: "stdout" | "stderr"; - text: string; -} - interface JavaScriptEntrypoint { evaluate( input: WorkspaceRuntimeValue, host: WorkspaceRuntimeBridge, context: WorkspaceExecutionContext, - ): Promise<{ result?: unknown; logs?: RuntimeLogEntry[]; error?: string }>; + ): Promise>; [Symbol.dispose]?: () => void; } @@ -416,17 +412,17 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { compatibilityFlags: this.#options.compatibilityFlags, maxLogBytes: this.#options.maxLogBytes, maxLogEvents: this.#options.maxLogEvents, - maxResultBytes: this.#options.maxResultBytes, maxSourceBytes: this.#options.maxSourceBytes, - onComplete: (outcome) => this.#complete(record, outcome), + onComplete: (frames) => this.#complete(record, frames), }); this.#host.waitUntil?.(record.control.completion); } catch (error) { record.control?.cancel(); await record.control?.completion.catch(() => undefined); - await this.#complete(record, { - error: error instanceof Error ? error.message : String(error), - }); + await this.#complete( + record, + errorFrames(error, Math.max(0, this.#options.maxLogBytes - 1)), + ); } return { id, events: this.#stream(record) }; } finally { @@ -575,21 +571,15 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { return record; } - #complete( - record: ExecutionRecord, - outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, - ): Promise { + #complete(record: ExecutionRecord, frames: RuntimeFrame[]): Promise { if (record.finalization) return record.finalization; if (record.status !== "running") return Promise.resolve(); - const finalization = this.#completeOnce(record, outcome); + const finalization = this.#completeOnce(record, frames); record.finalization = finalization; return finalization; } - async #completeOnce( - record: ExecutionRecord, - outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, - ) { + async #completeOnce(record: ExecutionRecord, frames: RuntimeFrame[]) { try { await record.bridge?.cancelAndDrain(); } catch (error) { @@ -607,49 +597,40 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { return; } try { - for (const log of outcome.logs ?? []) { - this.#append(record, { - id: record.id, - seq: record.events.length + 1, - name: log.stream, - value: new TextEncoder().encode(log.text), - }); - } - if (outcome.error !== undefined) { - this.#finish(record, "failed", [ - { + let result: WorkspaceRuntimeValue | undefined; + let hasResult = false; + let exitCode = 0; + for (const frame of frames) { + if (frame.name === "stdout" || frame.name === "stderr") { + this.#append(record, { id: record.id, seq: record.events.length + 1, - name: "stderr", - value: new TextEncoder().encode( - `${truncateUtf8(outcome.error, Math.max(0, this.#options.maxLogBytes - 1))}\n`, - ), - }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, - ]); - return; + name: frame.name, + value: frame.value, + }); + } else if (frame.name === "result") { + result = frame.value; + hasResult = true; + } else { + exitCode = frame.value; + } } - const result = outcome.result ?? null; - try { - assertRuntimeValue(result); - assertEncodedSize(result, this.#options.maxResultBytes, "result"); - this.#finish(record, "completed", [ - { id: record.id, seq: record.events.length + 1, name: "result", value: result }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 0 }, - ]); - } catch (error) { - this.#finish(record, "failed", [ - { - id: record.id, - seq: record.events.length + 1, - name: "stderr", - value: new TextEncoder().encode( - `${error instanceof Error ? error.message : String(error)}\n`, - ), - }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, - ]); + const terminal: WorkspaceRuntimeEvent[] = []; + if (exitCode === 0 && hasResult) { + terminal.push({ + id: record.id, + seq: record.events.length + 1, + name: "result", + value: result as WorkspaceRuntimeValue, + }); } + terminal.push({ + id: record.id, + seq: record.events.length + terminal.length + 1, + name: "exit", + value: exitCode, + }); + this.#finish(record, exitCode === 0 ? "completed" : "failed", terminal); } catch (error) { this.#finish(record, "failed", [ { @@ -889,13 +870,8 @@ function startJavaScriptExecution(options: { compatibilityFlags: string[]; maxLogBytes: number; maxLogEvents: number; - maxResultBytes: number; maxSourceBytes: number; - onComplete(outcome: { - result?: unknown; - logs?: RuntimeLogEntry[]; - error?: string; - }): void | Promise; + onComplete(frames: RuntimeFrame[]): void | Promise; }): ActiveControl { const modules = { ...options.modules, @@ -903,7 +879,6 @@ function startJavaScriptExecution(options: { options.entryName, options.maxLogBytes, options.maxLogEvents, - options.maxResultBytes, ), }; assertLoaderGraph(modules, options.maxSourceBytes); @@ -950,22 +925,26 @@ function startJavaScriptExecution(options: { cancellation, ]); const completion = execution - .then(async (outcome) => { + .then(async (stream) => { if (timer !== undefined) clearTimeout(timer); - dispose(); - await options.onComplete(outcome); + let frames: RuntimeFrame[]; + try { + frames = await drainFrames(stream); + } finally { + dispose(); + } + await options.onComplete(frames); }) .catch(async (error) => { if (timer !== undefined) clearTimeout(timer); dispose(); if (!cancelled) { - await options.onComplete({ - error: String(error).includes("hung and would never generate a response") - ? "JavaScript execution timed out" - : error instanceof Error - ? error.message - : String(error), - }); + const message = String(error).includes("hung and would never generate a response") + ? "JavaScript execution timed out" + : error instanceof Error + ? error.message + : String(error); + await options.onComplete(errorFrames(message, Math.max(0, options.maxLogBytes - 1))); } }) .finally(() => { @@ -984,12 +963,30 @@ function startJavaScriptExecution(options: { }; } -function runtimeWorkerModule( - entryName: string, - maxLogBytes: number, - maxLogEvents: number, - maxResultBytes: number, -) { +async function drainFrames(stream: ReadableStream): Promise { + const frames: RuntimeFrame[] = []; + const reader = decodeRuntimeFrames(stream).getReader(); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + frames.push(next.value); + } + } finally { + reader.releaseLock(); + } + return frames; +} + +function errorFrames(error: unknown, maxBytes: number): RuntimeFrame[] { + const message = error instanceof Error ? error.message : String(error); + return [ + { name: "stderr", value: new TextEncoder().encode(`${truncateUtf8(message, maxBytes)}\n`) }, + { name: "exit", value: 1 }, + ]; +} + +function runtimeWorkerModule(entryName: string, maxLogBytes: number, maxLogEvents: number) { return ` import { WorkerEntrypoint } from "cloudflare:workers"; import { install } from "workspace-capabilities.js"; @@ -1105,29 +1102,57 @@ function runtimeWorkerModule( globalThis.process.stderr = nextProcess.stderr; } catch {} } - install(host); - try { - const module = await import(${JSON.stringify(entryName)}); - const result = typeof module.default === "function" - ? await module.default(input) - : module.default ?? null; - if (encoder.encode(JSON.stringify(result)).byteLength > ${maxResultBytes}) { - throw new Error("Workspace runtime result exceeds ${maxResultBytes} bytes."); + const frames = []; + const toBase64 = (text) => { + const bytes = encoder.encode(text); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); } - return { result, logs }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + return btoa(binary); + }; + const emitLogs = () => { + for (const log of logs) { + frames.push(JSON.stringify({ name: log.stream, b64: toBase64(log.text) })); + } + }; + const truncate = (message) => { const bytes = encoder.encode(message); - let prefix = bytes.slice(0, ${maxLogBytes}); + let prefix = bytes.slice(0, ${maxLogBytes - 1}); while (prefix.byteLength > 0) { try { - return { error: new TextDecoder("utf-8", { fatal: true }).decode(prefix), logs }; + return new TextDecoder("utf-8", { fatal: true }).decode(prefix); } catch { prefix = prefix.slice(0, -1); } } - return { error: "", logs }; + return ""; + }; + install(host); + try { + const module = await import(${JSON.stringify(entryName)}); + const result = typeof module.default === "function" + ? await module.default(input) + : module.default ?? null; + const value = result ?? null; + await host.assertResult(value); + emitLogs(); + frames.push(JSON.stringify({ name: "result", value })); + frames.push(JSON.stringify({ name: "exit", value: 0 })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitLogs(); + frames.push(JSON.stringify({ name: "stderr", b64: toBase64(truncate(message) + "\\n") })); + frames.push(JSON.stringify({ name: "exit", value: 1 })); } + return new ReadableStream({ + start(controller) { + for (const frame of frames) { + controller.enqueue(encoder.encode(frame + "\\n")); + } + controller.close(); + }, + }); } } `; From d8fdf2b055fd155b90cfa47eb1f22165672d5bb4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:05:25 +0000 Subject: [PATCH 5/9] computer: cap JavaScript stdout and stderr with a single byte ceiling Replace the separate maxLogBytes and maxLogEvents limits with one maxStdioBytes that bounds combined stdout and stderr for an execution. A run is bounded by total output rather than per stream or per event, matching how a shell exec is bounded. The runner keeps a single byte counter and emits one "...[stdio truncated]" marker on the write that overflows, when the remaining budget still admits the marker. The event-count ceiling is gone; many small writes are bounded purely by bytes. Update the capping tests and the backend documentation. --- docs/17_isolate_javascript.md | 4 +- .../worker-javascript/worker-javascript.ts | 57 +++++++------------ .../computer/tests/script-runner-worker.ts | 3 +- packages/computer/tests/script-runner.test.ts | 11 ++-- 4 files changed, 29 insertions(+), 46 deletions(-) diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 800e425d..c3d2f5df 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -83,7 +83,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 log events, active event subscribers, directory entries per read, concurrent and total capability calls, and cumulative capability request and response bytes. The corresponding `maxLogEvents`, `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. 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. @@ -185,7 +185,7 @@ Each execution receives a fresh Dynamic Worker with: - a host wall-clock deadline; - `globalOutbound: null` by default; - finite, acyclic JSON-compatible input and structured result validation; -- configurable source/module graph, input, result, stdin, captured-log, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxStdinBytes`, `maxLogBytes`, and `maxCapabilityBytes`); +- configurable source/module graph, input, result, stdin, stdio, file/capability request, and response byte limits (`maxSourceBytes`, `maxInputBytes`, `maxResultBytes`, `maxStdinBytes`, `maxStdioBytes`, and `maxCapabilityBytes`); - explicit entrypoint and Worker disposal; - host-owned cancellation; - retained events and result rows in the Workspace database. diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 6fa35223..24f2b4c4 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -33,8 +33,7 @@ export interface WorkerJavaScriptBackendOptions { maxStdinBytes?: number; maxEnvBytes?: number; maxResultBytes?: number; - maxLogBytes?: number; - maxLogEvents?: number; + maxStdioBytes?: number; maxCapabilityBytes?: number; /** Caller-visible deadline for one host capability call. */ maxHostCallMs?: number; @@ -73,8 +72,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "maxStdinBytes" | "maxEnvBytes" | "maxResultBytes" - | "maxLogBytes" - | "maxLogEvents" + | "maxStdioBytes" | "maxCapabilityBytes" | "maxHostCallMs" | "maxConcurrentCapabilityCalls" @@ -149,8 +147,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes"); - assertPositiveFinite(options.maxLogBytes ?? 256 * 1024, "maxLogBytes"); - assertPositiveInteger(options.maxLogEvents ?? 1024, "maxLogEvents"); + assertPositiveFinite(options.maxStdioBytes ?? 1024 * 1024, "maxStdioBytes"); assertPositiveFinite(options.maxCapabilityBytes ?? 1024 * 1024, "maxCapabilityBytes"); assertPositiveFinite(options.maxHostCallMs ?? maxTimeoutMs, "maxHostCallMs"); assertPositiveInteger( @@ -192,8 +189,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, maxResultBytes: options.maxResultBytes ?? 1024 * 1024, - maxLogBytes: options.maxLogBytes ?? 256 * 1024, - maxLogEvents: options.maxLogEvents ?? 1024, + maxStdioBytes: options.maxStdioBytes ?? 1024 * 1024, maxCapabilityBytes: options.maxCapabilityBytes ?? 1024 * 1024, maxHostCallMs: options.maxHostCallMs ?? maxTimeoutMs, maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 32, @@ -410,8 +406,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { globalOutbound: this.#options.globalOutbound ?? null, compatibilityDate: this.#options.compatibilityDate, compatibilityFlags: this.#options.compatibilityFlags, - maxLogBytes: this.#options.maxLogBytes, - maxLogEvents: this.#options.maxLogEvents, + maxStdioBytes: this.#options.maxStdioBytes, maxSourceBytes: this.#options.maxSourceBytes, onComplete: (frames) => this.#complete(record, frames), }); @@ -421,7 +416,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { await record.control?.completion.catch(() => undefined); await this.#complete( record, - errorFrames(error, Math.max(0, this.#options.maxLogBytes - 1)), + errorFrames(error, Math.max(0, this.#options.maxStdioBytes - 1)), ); } return { id, events: this.#stream(record) }; @@ -868,18 +863,13 @@ function startJavaScriptExecution(options: { globalOutbound: Fetcher | null; compatibilityDate: string; compatibilityFlags: string[]; - maxLogBytes: number; - maxLogEvents: number; + maxStdioBytes: number; maxSourceBytes: number; onComplete(frames: RuntimeFrame[]): void | Promise; }): ActiveControl { const modules = { ...options.modules, - "workspace-runtime-runner.js": runtimeWorkerModule( - options.entryName, - options.maxLogBytes, - options.maxLogEvents, - ), + "workspace-runtime-runner.js": runtimeWorkerModule(options.entryName, options.maxStdioBytes), }; assertLoaderGraph(modules, options.maxSourceBytes); const worker = options.loader.load({ @@ -944,7 +934,7 @@ function startJavaScriptExecution(options: { : error instanceof Error ? error.message : String(error); - await options.onComplete(errorFrames(message, Math.max(0, options.maxLogBytes - 1))); + await options.onComplete(errorFrames(message, Math.max(0, options.maxStdioBytes - 1))); } }) .finally(() => { @@ -986,7 +976,7 @@ function errorFrames(error: unknown, maxBytes: number): RuntimeFrame[] { ]; } -function runtimeWorkerModule(entryName: string, maxLogBytes: number, maxLogEvents: number) { +function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ` import { WorkerEntrypoint } from "cloudflare:workers"; import { install } from "workspace-capabilities.js"; @@ -1041,25 +1031,18 @@ function runtimeWorkerModule(entryName: string, maxLogBytes: number, maxLogEvent } const logs = []; const encoder = new TextEncoder(); - let logBytes = 0; - let logEvents = 0; - let logsTruncated = false; + let stdioBytes = 0; + let stdioTruncated = false; const record = (stream, text) => { - if (logsTruncated) return; - if (logEvents >= ${maxLogEvents - 1}) { - logs.push({ stream, text: "...[logs truncated]" }); - logsTruncated = true; - return; - } + if (stdioTruncated) return; const bytes = encoder.encode(text); - const remaining = ${maxLogBytes} - logBytes; + const remaining = ${maxStdioBytes} - stdioBytes; if (bytes.byteLength <= remaining) { logs.push({ stream, text }); - logBytes += bytes.byteLength; - logEvents += 1; + stdioBytes += bytes.byteLength; return; } - const marker = encoder.encode("...[logs truncated]"); + const marker = encoder.encode("...[stdio truncated]"); const available = remaining - marker.byteLength; if (available >= 0) { let head = bytes.slice(0, available); @@ -1072,10 +1055,10 @@ function runtimeWorkerModule(entryName: string, maxLogBytes: number, maxLogEvent head = head.slice(0, -1); } } - logs.push({ stream, text: partial + "...[logs truncated]" }); - logBytes += head.byteLength + marker.byteLength; + logs.push({ stream, text: partial + "...[stdio truncated]" }); + stdioBytes += head.byteLength + marker.byteLength; } - logsTruncated = true; + stdioTruncated = true; }; const consoleLine = (stream, args) => record(stream, args.map(String).join(" ") + "\\n"); console.log = (...args) => consoleLine("stdout", args); @@ -1118,7 +1101,7 @@ function runtimeWorkerModule(entryName: string, maxLogBytes: number, maxLogEvent }; const truncate = (message) => { const bytes = encoder.encode(message); - let prefix = bytes.slice(0, ${maxLogBytes - 1}); + let prefix = bytes.slice(0, ${maxStdioBytes - 1}); while (prefix.byteLength > 0) { try { return new TextDecoder("utf-8", { fatal: true }).decode(prefix); diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 65a5f89c..244b548b 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -25,8 +25,7 @@ export class HostDO extends DurableObject { backends: [ new WorkerJavaScriptBackend({ loader: env.LOADER, - maxLogBytes: 64, - maxLogEvents: 4, + maxStdioBytes: 64, maxCapabilityBytes: 1024, maxConcurrentCapabilityCalls: 2, modules: { diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 1a67d917..61c8b63f 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -227,7 +227,7 @@ describe("WorkspaceRuntime", () => { const payload = JSON.parse(text); expect(payload.result.status).toBe("completed"); expect(new TextEncoder().encode(payload.result.stdout).byteLength).toBeLessThanOrEqual(64); - expect(payload.result.stdout).toContain("logs truncated"); + expect(payload.result.stdout).toContain("stdio truncated"); }); it("bounds oversized trusted-module error responses", async () => { @@ -245,16 +245,17 @@ describe("WorkspaceRuntime", () => { expect(new TextEncoder().encode(payload.result.stderr).byteLength).toBeLessThanOrEqual(64); }); - it("bounds log event amplification independently of log bytes", async () => { + it("bounds many small writes by the shared stdio byte ceiling", async () => { const response = await runtime({ - source: `export default () => { for (let i = 0; i < 20; i++) console.log(""); return true; };`, + source: `export default () => { for (let i = 0; i < 100; i++) console.log("xy"); return true; };`, cwd: "/workspace", }); const text = await response.text(); expect(response.status, text).toBe(200); const payload = JSON.parse(text); - expect(payload.result.stdout.split("\n").filter(Boolean)).toEqual(["...[logs truncated]"]); - expect(payload.result.stdout.split("\n").length - 1).toBe(3); + expect(payload.result.status).toBe("completed"); + expect(new TextEncoder().encode(payload.result.stdout).byteLength).toBeLessThanOrEqual(64); + expect(payload.result.stdout.split("\n").filter(Boolean).length).toBeLessThan(100); }); it("bounds concurrent host capability calls", async () => { From ec7c7748412d9b088e66549909ce3a6290e997cc Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:21:33 +0000 Subject: [PATCH 6/9] computer: stream JavaScript execution output live to the host Deliver stdout and stderr as they are produced rather than buffering the whole run, matching how the container backend streams exec events. The runner no longer returns its framed stream as the evaluate result, because a returned value cannot keep the host bridge alive: Workers RPC disposes an argument stub when the call that received it returns, so a detached writer would fail with "RPC stub used after being disposed." Instead the isolate hands the readable end to the new bridge attachOutput method as a call argument and awaits it, which keeps evaluate in flight for the whole execution and holds the bridge alive. The host drains that stream through a live pump, ingesting each frame into the event log the moment it arrives and settling the terminal result and exit once the stream closes. Add a backend test asserting a stdout event is observable before user code returns. --- .../worker-javascript.test.ts | 103 ++++++- .../worker-javascript/worker-javascript.ts | 253 +++++++++--------- packages/computer/src/runtime/bridge.ts | 13 + 3 files changed, 227 insertions(+), 142 deletions(-) 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 3b0c2870..2e8994d9 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -21,12 +21,16 @@ function throwingLoader(message: string) { }; } -// Frame a successful result the way the real runner does: validate -// through the bridge, then emit a result frame and a zero exit. -async function resultStream( - host: { assertResult(value: unknown): Promise }, +// Drive a successful result the way the real runner does: validate +// through the bridge, frame result + exit, hand the readable to +// attachOutput, and stay "in flight" until the host finishes draining. +async function evaluateResult( + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, value: unknown, -): Promise> { +): Promise { const frames: string[] = []; try { await host.assertResult(value); @@ -38,12 +42,13 @@ async function resultStream( frames.push(JSON.stringify({ name: "exit", value: 1 })); } const encoder = new TextEncoder(); - return new ReadableStream({ + const readable = new ReadableStream({ start(controller) { for (const frame of frames) controller.enqueue(encoder.encode(`${frame}\n`)); controller.close(); }, }); + await host.attachOutput(readable); } describe("WorkerJavaScriptBackend", () => { @@ -185,8 +190,13 @@ describe("WorkerJavaScriptBackend", () => { const load = vi.fn(() => ({ getEntrypoint() { return { - evaluate: (_input: unknown, host: { assertResult(value: unknown): Promise }) => - resultStream(host, "result-too-large"), + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, "result-too-large"), }; }, })); @@ -420,8 +430,11 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate: ( _input: unknown, - host: { assertResult(value: unknown): Promise }, - ) => evaluation.then((outcome) => resultStream(host, outcome.result)), + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluation.then((outcome) => evaluateResult(host, outcome.result)), }; }, }; @@ -469,10 +482,11 @@ describe("WorkerJavaScriptBackend", () => { host: { call(name: string, args: string): Promise; assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; }, ) { void host.call("fs.writeFile", JSON.stringify(["/workspace/output.txt", "done"])); - return resultStream(host, 1); + return evaluateResult(host, 1); }, }; }, @@ -502,6 +516,65 @@ describe("WorkerJavaScriptBackend", () => { expect(events.at(-1)).toMatchObject({ name: "exit", value: 0 }); }); + it("streams stdout before user code returns", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + let releaseExit!: () => void; + const exitReleased = new Promise((resolve) => { + releaseExit = resolve; + }); + const encoder = new TextEncoder(); + const backend = new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { attachOutput(readable: ReadableStream): Promise }, + ) { + const readable = new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ name: "stdout", b64: btoa("live\n") })}\n`, + ), + ); + await exitReleased; + controller.enqueue( + encoder.encode(`${JSON.stringify({ name: "exit", value: 0 })}\n`), + ); + controller.close(); + }, + }); + await host.attachOutput(readable); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "live-stream", source: "export default 1" }); + const reader = execution.events.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + expect(first.value).toMatchObject({ name: "stdout" }); + expect(new TextDecoder().decode((first.value as { value: Uint8Array }).value)).toBe("live\n"); + releaseExit(); + reader.releaseLock(); + await handle.close(); + }); + it("aborts cooperative trusted-module calls at their deadline", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); @@ -532,7 +605,6 @@ describe("WorkerJavaScriptBackend", () => { host: { call(name: string, args: string): Promise }, ) { await host.call("trusted/ws:test.call", JSON.stringify(["run"])); - return { result: 1 }; }, }; }, @@ -640,8 +712,11 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate: ( _input: unknown, - bridge: { assertResult(value: unknown): Promise }, - ) => evaluation.then((outcome) => resultStream(bridge, outcome.result)), + bridge: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluation.then((outcome) => evaluateResult(bridge, outcome.result)), }; }, }; diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 24f2b4c4..608d18ef 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -101,7 +101,7 @@ interface JavaScriptEntrypoint { input: WorkspaceRuntimeValue, host: WorkspaceRuntimeBridge, context: WorkspaceExecutionContext, - ): Promise>; + ): Promise; [Symbol.dispose]?: () => void; } @@ -126,6 +126,9 @@ interface ExecutionRecord { finalization?: Promise; admitted?: boolean; persistenceFailed?: boolean; + result?: WorkspaceRuntimeValue; + hasResult?: boolean; + exitCode?: number; } export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { @@ -389,6 +392,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { maxTotalRequestBytes: this.#options.maxCapabilityRequestBytes, maxTotalResponseBytes: this.#options.maxCapabilityResponseBytes, maxResultBytes: this.#options.maxResultBytes, + onAttachOutput: (readable) => this.#pumpFrames(record, readable), }); record.bridge = bridge; record.control = startJavaScriptExecution({ @@ -408,16 +412,14 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { compatibilityFlags: this.#options.compatibilityFlags, maxStdioBytes: this.#options.maxStdioBytes, maxSourceBytes: this.#options.maxSourceBytes, - onComplete: (frames) => this.#complete(record, frames), + onComplete: () => this.#finalize(record), + onError: (message) => this.#finalize(record, message), }); this.#host.waitUntil?.(record.control.completion); } catch (error) { record.control?.cancel(); await record.control?.completion.catch(() => undefined); - await this.#complete( - record, - errorFrames(error, Math.max(0, this.#options.maxStdioBytes - 1)), - ); + await this.#finalize(record, error instanceof Error ? error.message : String(error)); } return { id, events: this.#stream(record) }; } finally { @@ -566,15 +568,51 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { return record; } - #complete(record: ExecutionRecord, frames: RuntimeFrame[]): Promise { + // Drain the runner's framed output stream live, ingesting each frame + // as it arrives. Resolves when the stream closes, which is the + // signal the runner's evaluate awaits before returning. + async #pumpFrames(record: ExecutionRecord, readable: ReadableStream) { + const reader = decodeRuntimeFrames(readable).getReader(); + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + this.#ingestFrame(record, next.value); + } + } finally { + reader.releaseLock(); + } + } + + // Append a stdout/stderr frame the moment it arrives, so output is + // observable before user code returns. Result and exit ride the same + // stream but only settle the terminal state in #finalize. + #ingestFrame(record: ExecutionRecord, frame: RuntimeFrame) { + if (record.status !== "running") return; + if (frame.name === "stdout" || frame.name === "stderr") { + this.#append(record, { + id: record.id, + seq: record.events.length + 1, + name: frame.name, + value: frame.value, + }); + } else if (frame.name === "result") { + record.result = frame.value; + record.hasResult = true; + } else { + record.exitCode = frame.value; + } + } + + #finalize(record: ExecutionRecord, errorMessage?: string): Promise { if (record.finalization) return record.finalization; if (record.status !== "running") return Promise.resolve(); - const finalization = this.#completeOnce(record, frames); + const finalization = this.#finalizeOnce(record, errorMessage); record.finalization = finalization; return finalization; } - async #completeOnce(record: ExecutionRecord, frames: RuntimeFrame[]) { + async #finalizeOnce(record: ExecutionRecord, errorMessage?: string) { try { await record.bridge?.cancelAndDrain(); } catch (error) { @@ -591,54 +629,37 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { ]); return; } - try { - let result: WorkspaceRuntimeValue | undefined; - let hasResult = false; - let exitCode = 0; - for (const frame of frames) { - if (frame.name === "stdout" || frame.name === "stderr") { - this.#append(record, { - id: record.id, - seq: record.events.length + 1, - name: frame.name, - value: frame.value, - }); - } else if (frame.name === "result") { - result = frame.value; - hasResult = true; - } else { - exitCode = frame.value; - } - } - const terminal: WorkspaceRuntimeEvent[] = []; - if (exitCode === 0 && hasResult) { - terminal.push({ - id: record.id, - seq: record.events.length + 1, - name: "result", - value: result as WorkspaceRuntimeValue, - }); - } - terminal.push({ - id: record.id, - seq: record.events.length + terminal.length + 1, - name: "exit", - value: exitCode, - }); - this.#finish(record, exitCode === 0 ? "completed" : "failed", terminal); - } catch (error) { + if (errorMessage !== undefined) { this.#finish(record, "failed", [ { id: record.id, seq: record.events.length + 1, name: "stderr", value: new TextEncoder().encode( - `Execution finalization failed: ${error instanceof Error ? error.message : String(error)}\n`, + `${truncateUtf8(errorMessage, Math.max(0, this.#options.maxStdioBytes - 1))}\n`, ), }, { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, ]); + return; + } + const exitCode = record.exitCode ?? 0; + const terminal: WorkspaceRuntimeEvent[] = []; + if (exitCode === 0 && record.hasResult) { + terminal.push({ + id: record.id, + seq: record.events.length + 1, + name: "result", + value: record.result as WorkspaceRuntimeValue, + }); } + terminal.push({ + id: record.id, + seq: record.events.length + terminal.length + 1, + name: "exit", + value: exitCode, + }); + this.#finish(record, exitCode === 0 ? "completed" : "failed", terminal); } #stream(record: ExecutionRecord, after?: number | "tail") { @@ -865,7 +886,8 @@ function startJavaScriptExecution(options: { compatibilityFlags: string[]; maxStdioBytes: number; maxSourceBytes: number; - onComplete(frames: RuntimeFrame[]): void | Promise; + onComplete(): void | Promise; + onError(message: string): void | Promise; }): ActiveControl { const modules = { ...options.modules, @@ -902,45 +924,44 @@ function startJavaScriptExecution(options: { const cancellation = new Promise((_, reject) => { cancelExecution = reject; }); - const execution = Promise.race([ - Promise.resolve().then(() => - entrypoint.evaluate(options.input, options.bridge, options.context), - ), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error("JavaScript execution timed out")), - options.timeoutMs, - ); - }), - cancellation, - ]); - const completion = execution - .then(async (stream) => { + // evaluate stays in flight for the whole run: it pushes its framed + // output stream to the host through the bridge (drained live there) + // and resolves only once user code returns and the stream closes. + const run = Promise.resolve().then(() => + entrypoint.evaluate(options.input, options.bridge, options.context), + ); + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("JavaScript execution timed out")), + options.timeoutMs, + ); + }); + const completion = Promise.race([run, timeout, cancellation]) + .then(async () => { if (timer !== undefined) clearTimeout(timer); - let frames: RuntimeFrame[]; - try { - frames = await drainFrames(stream); - } finally { - dispose(); - } - await options.onComplete(frames); + dispose(); + await options.onComplete(); }) .catch(async (error) => { if (timer !== undefined) clearTimeout(timer); dispose(); if (!cancelled) { - const message = String(error).includes("hung and would never generate a response") - ? "JavaScript execution timed out" - : error instanceof Error - ? error.message - : String(error); - await options.onComplete(errorFrames(message, Math.max(0, options.maxStdioBytes - 1))); + await options.onError( + String(error).includes("hung and would never generate a response") + ? "JavaScript execution timed out" + : error instanceof Error + ? error.message + : String(error), + ); } }) .finally(() => { if (timer !== undefined) clearTimeout(timer); dispose(); }); + // The stream read can reject after a timeout or cancel already + // settled the race; swallow that late rejection. + run.catch(() => undefined); return { completion, cancel() { @@ -953,29 +974,6 @@ function startJavaScriptExecution(options: { }; } -async function drainFrames(stream: ReadableStream): Promise { - const frames: RuntimeFrame[] = []; - const reader = decodeRuntimeFrames(stream).getReader(); - try { - while (true) { - const next = await reader.read(); - if (next.done) break; - frames.push(next.value); - } - } finally { - reader.releaseLock(); - } - return frames; -} - -function errorFrames(error: unknown, maxBytes: number): RuntimeFrame[] { - const message = error instanceof Error ? error.message : String(error); - return [ - { name: "stderr", value: new TextEncoder().encode(`${truncateUtf8(message, maxBytes)}\n`) }, - { name: "exit", value: 1 }, - ]; -} - function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ` import { WorkerEntrypoint } from "cloudflare:workers"; @@ -1029,8 +1027,22 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { globalThis.process.stdin = stdin; } catch {} } - const logs = []; const encoder = new TextEncoder(); + const output = new IdentityTransformStream(); + const writer = output.writable.getWriter(); + const toBase64 = (text) => { + const bytes = encoder.encode(text); + let binary = ""; + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + return btoa(binary); + }; + const enqueue = (frame) => { + try { + writer.write(encoder.encode(JSON.stringify(frame) + "\\n")); + } catch {} + }; let stdioBytes = 0; let stdioTruncated = false; const record = (stream, text) => { @@ -1038,7 +1050,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { const bytes = encoder.encode(text); const remaining = ${maxStdioBytes} - stdioBytes; if (bytes.byteLength <= remaining) { - logs.push({ stream, text }); + enqueue({ name: stream, b64: toBase64(text) }); stdioBytes += bytes.byteLength; return; } @@ -1055,7 +1067,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { head = head.slice(0, -1); } } - logs.push({ stream, text: partial + "...[stdio truncated]" }); + enqueue({ name: stream, b64: toBase64(partial + "...[stdio truncated]") }); stdioBytes += head.byteLength + marker.byteLength; } stdioTruncated = true; @@ -1085,20 +1097,6 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { globalThis.process.stderr = nextProcess.stderr; } catch {} } - const frames = []; - const toBase64 = (text) => { - const bytes = encoder.encode(text); - let binary = ""; - for (let index = 0; index < bytes.length; index += 1) { - binary += String.fromCharCode(bytes[index]); - } - return btoa(binary); - }; - const emitLogs = () => { - for (const log of logs) { - frames.push(JSON.stringify({ name: log.stream, b64: toBase64(log.text) })); - } - }; const truncate = (message) => { const bytes = encoder.encode(message); let prefix = bytes.slice(0, ${maxStdioBytes - 1}); @@ -1112,6 +1110,11 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ""; }; install(host); + // Hand the readable end to the host, which drains it live while + // this call stays in flight. Keeping evaluate in flight is what + // holds the host bridge stub alive for the whole run; frames + // enqueue as output is produced. + const drained = host.attachOutput(output.readable); try { const module = await import(${JSON.stringify(entryName)}); const result = typeof module.default === "function" @@ -1119,23 +1122,17 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { : module.default ?? null; const value = result ?? null; await host.assertResult(value); - emitLogs(); - frames.push(JSON.stringify({ name: "result", value })); - frames.push(JSON.stringify({ name: "exit", value: 0 })); + enqueue({ name: "result", value }); + enqueue({ name: "exit", value: 0 }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - emitLogs(); - frames.push(JSON.stringify({ name: "stderr", b64: toBase64(truncate(message) + "\\n") })); - frames.push(JSON.stringify({ name: "exit", value: 1 })); + enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); + enqueue({ name: "exit", value: 1 }); } - return new ReadableStream({ - start(controller) { - for (const frame of frames) { - controller.enqueue(encoder.encode(frame + "\\n")); - } - controller.close(); - }, - }); + try { + await writer.close(); + } catch {} + await drained; } } `; diff --git a/packages/computer/src/runtime/bridge.ts b/packages/computer/src/runtime/bridge.ts index c634cc71..552e28f0 100644 --- a/packages/computer/src/runtime/bridge.ts +++ b/packages/computer/src/runtime/bridge.ts @@ -19,6 +19,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { readonly #maxTotalRequestBytes: number; readonly #maxTotalResponseBytes: number; readonly #maxResultBytes: number; + readonly #onAttachOutput?: (readable: ReadableStream) => Promise; readonly #inFlight = new Set>(); readonly #abortControllers = new Set(); #cancelled = false; @@ -42,6 +43,7 @@ export class WorkspaceRuntimeBridge extends RpcTarget { maxTotalRequestBytes?: number; maxTotalResponseBytes?: number; maxResultBytes?: number; + onAttachOutput?: (readable: ReadableStream) => Promise; } = {}, ) { super(); @@ -58,6 +60,17 @@ export class WorkspaceRuntimeBridge extends RpcTarget { this.#maxTotalRequestBytes = integrations.maxTotalRequestBytes ?? 8 * 1024 * 1024; this.#maxTotalResponseBytes = integrations.maxTotalResponseBytes ?? 8 * 1024 * 1024; this.#maxResultBytes = integrations.maxResultBytes ?? 1024 * 1024; + this.#onAttachOutput = integrations.onAttachOutput; + } + + // Drain the runner's framed output stream. The isolate passes the + // readable end as a call argument (the direction that transfers a + // live byte stream over the loader boundary) and keeps this call + // in flight until it closes, which is what holds the bridge stub + // alive for the whole execution. The host consumer reads frames as + // they arrive, so output is observable before user code returns. + async attachOutput(readable: ReadableStream): Promise { + await this.#onAttachOutput?.(readable); } // Validate an execution result before the runner frames it as JSON. From a9818b4c398ceb66d3d1a43783e5aee74fa64104 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:59:56 +0000 Subject: [PATCH 7/9] computer, docs: describe live streaming and harden the output pump Rewrite the JavaScript runtime doc to describe live standard output and standard error streaming through the attachOutput bridge call, replacing the stale paragraph that said output was buffered until evaluation settled. Guard the host output pump against the read rejection that a worker disposed on timeout or cancellation raises: once the record is no longer running the rejection is a normal end-of-drain, while a rejection during a still-running execution still surfaces as a fault. Add a test that cancels mid-stream and asserts the run settles at the kill exit with no output after it. --- docs/17_isolate_javascript.md | 4 +- .../worker-javascript.test.ts | 63 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 18 +++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index c3d2f5df..de3396c1 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -99,7 +99,7 @@ Each execution installs a small `node:process` shim so ordinary module code can `process.stdin` is a non-interactive async-iterable over the caller-supplied `stdin` bytes. The caller passes `stdin` as a `Uint8Array` or string on the exec options; `for await` yields the bytes once and then ends, and there is no blocking read for further input because an evaluate-once execution has no session to wait on. `isTTY` is `false`. The supplied input is bounded by `maxStdinBytes`; exceeding it fails the run with a clear error. -`process.stdout` and `process.stderr` are writable streams whose writes are captured as standard output and standard error. `console.log` and `console.info` route to standard output, `console.warn` and `console.error` route to standard error, and the captured output is bounded. `process.argv`, `process.cwd()`, and `process.platform` return inert values: `cwd()` reflects the execution's working directory, while `argv` and `platform` carry fixed placeholders rather than describing the host process. +`process.stdout` and `process.stderr` are writable streams whose writes flow to the live output described under Isolation and lifecycle. `console.log` and `console.info` route to standard output, `console.warn` and `console.error` route to standard error, and both share the single `maxStdioBytes` ceiling. `process.argv`, `process.cwd()`, and `process.platform` return inert values: `cwd()` reflects the execution's working directory, while `argv` and `platform` carry fixed placeholders rather than describing the host process. ```ts const handle = await workspace.runtime.exec( @@ -190,7 +190,7 @@ Each execution receives a fresh Dynamic Worker with: - host-owned cancellation; - retained events and result rows in the Workspace database. -Console output is bounded but currently buffered in the Dynamic Worker and published when evaluation settles; the execution event stream provides replay/lifecycle semantics rather than live JavaScript console streaming. Completed writes are durable immediately. Failure or cancellation does not roll back filesystem effects already completed. +Standard output and standard error stream live. The Dynamic Worker hands the readable end of its output stream to the host through the `attachOutput` bridge call, and the host drains it frame by frame while user code is still running, appending each chunk to the execution event stream as it arrives rather than buffering the run and publishing at the end. The structured result and the exit event settle once the output stream closes, so the terminal events always follow the last output. Output remains bounded by `maxStdioBytes` across both streams. Completed writes are durable immediately. Failure or cancellation does not roll back filesystem effects already completed. ## Trusted integrations 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 2e8994d9..69e7b492 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -575,6 +575,69 @@ describe("WorkerJavaScriptBackend", () => { await handle.close(); }); + it("stops draining and settles with the kill exit when cancelled mid-stream", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + const encoder = new TextEncoder(); + let streamController!: ReadableStreamDefaultController; + const backend = new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { attachOutput(readable: ReadableStream): Promise }, + ) { + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue( + encoder.encode( + `${JSON.stringify({ name: "stdout", b64: btoa("live\n") })}\n`, + ), + ); + // Stays open with no exit frame: the run is torn down + // by cancellation rather than finishing on its own. + }, + }); + await host.attachOutput(readable); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "kill-mid-stream", source: "export default 1" }); + const reader = execution.events.getReader(); + const first = await reader.read(); + expect(first.value).toMatchObject({ name: "stdout" }); + reader.releaseLock(); + await handle.killExec({ id: execution.id }); + // Cancellation disposes the Dynamic Worker, which errors the transferred + // output stream. Mirror that so the live pump's pending read rejects + // after the record has already settled. + streamController.error(new Error("worker disposed")); + const events = []; + for await (const event of execution.events) events.push(event); + const exitIndex = events.findIndex((event) => event.name === "exit"); + expect(exitIndex).toBeGreaterThanOrEqual(0); + expect(events[exitIndex]).toMatchObject({ name: "exit", value: 130 }); + // The exit event is terminal: no stdout, stderr, or result follows it. + expect(events.slice(exitIndex + 1)).toEqual([]); + await handle.close(); + }); + it("aborts cooperative trusted-module calls at their deadline", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 608d18ef..476fff29 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -575,7 +575,23 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { const reader = decodeRuntimeFrames(readable).getReader(); try { while (true) { - const next = await reader.read(); + let next: ReadableStreamReadResult; + try { + next = await reader.read(); + } catch (error) { + // Timeout or cancellation disposes the Dynamic Worker while this + // pump may be blocked on read(); the disposed isolate errors the + // transferred stream and the read rejects. When that rejection + // arrives after the record has already settled, treat it as a + // normal end-of-drain. When it arrives while the execution is + // still running it is re-thrown: on the timeout/cancel path that + // is harmless (it rejects the already-lost evaluate race, which + // run.catch swallows, and cancelAndDrain never awaits this pump), + // while on a genuine mid-run stream fault it correctly surfaces + // as a failed terminal. + if (record.status !== "running") break; + throw error; + } if (next.done) break; this.#ingestFrame(record, next.value); } From e4c85619a529730b5bd013cfa76d1401be2e9fce Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:11:45 +0000 Subject: [PATCH 8/9] computer, rpc, docs: forward env through the command execution path Environment variables reached the JavaScript module backend through process.env but were dropped on the command path: the runtime command branch, the shell facade, the shell RPC contract, and the worker shell backend all discarded env. Thread env end to end so a command backend receives it too. The runtime command branch now forwards env to the shell, WorkspaceShell passes it on the exec envelope, the ShellRPC contract and its server carry it to the runner, and the worker shell backend and entrypoint hand it to just-bash. The container runner already merged a per-execution env over its base environment; it now receives one from the wire. Document env on the runtime interface as accepted everywhere: command backends inherit it for the spawned command and the JavaScript backend exposes it through process.env, applying to that execution only. Cover the command path with tests at the entrypoint, worker shell backend, workspace selection, and container runner layers. --- docs/05_runtime_interface.md | 4 +- .../backends/worker-shell/entrypoint.test.ts | 15 ++++++- .../src/backends/worker-shell/entrypoint.ts | 5 ++- .../worker-shell/worker-shell.test.ts | 39 ++++++++++++++++++- .../src/backends/worker-shell/worker-shell.ts | 9 ++++- packages/computer/src/runtime/runtime.ts | 1 + packages/computer/src/shell.ts | 5 +++ packages/computer/src/workspace.test.ts | 39 ++++++++++++++++--- packages/computerd/src/exec/runner.test.ts | 25 ++++++++++++ packages/rpc/src/interface.ts | 2 + packages/rpc/src/server.ts | 11 +++++- 11 files changed, 142 insertions(+), 13 deletions(-) diff --git a/docs/05_runtime_interface.md b/docs/05_runtime_interface.md index 0d642c3c..64a57485 100644 --- a/docs/05_runtime_interface.md +++ b/docs/05_runtime_interface.md @@ -31,6 +31,8 @@ interface WorkspaceRuntimeExecOptions { encoding?: "utf8"; input?: WorkspaceRuntimeValue; timeoutMs?: number; + env?: Record; + stdin?: Uint8Array | string; } interface WorkspaceRuntimeExecHandle extends ReadableStream { @@ -42,7 +44,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream; signal?: AbortSignal }, ) => Promise<{ stdout: string; stderr: string; exitCode: number }>, ): TestShellWorker { const w = new TestShellWorker(undefined as never, env as never); @@ -190,6 +190,19 @@ describe("ShellWorker", () => { expect(observedCwd).toBe("/workspace/src"); }); + it("forwards per-execution environment variables to Bash", async () => { + let observedEnv: Record | undefined; + const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => { + observedEnv = options.env; + return { stdout: "", stderr: "", exitCode: 0 }; + }); + await drain( + (await worker.exec({ command: "printenv TOKEN", env: { TOKEN: "secret", EMPTY: "" } })) + .events, + ); + expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" }); + }); + it("getExec without a prior exec throws ENOENT", async () => { const worker = new TestShellWorker(undefined as never, fakeEnv() as never); await expect(worker.getExec({ id: "missing" })).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/packages/computer/src/backends/worker-shell/entrypoint.ts b/packages/computer/src/backends/worker-shell/entrypoint.ts index 84e8a423..354923da 100644 --- a/packages/computer/src/backends/worker-shell/entrypoint.ts +++ b/packages/computer/src/backends/worker-shell/entrypoint.ts @@ -30,6 +30,7 @@ export interface ExecInput { cwd?: string; id?: string; timeoutMs?: number; + env?: Record; } export interface ShellWorkerOptions { @@ -105,6 +106,7 @@ export class ShellWorker< command: string, options: { cwd?: string; + env?: Record; signal?: AbortSignal; customCommands: CustomCommand[]; }, @@ -171,6 +173,7 @@ export class ShellWorker< if (this.bashFactoryOverride !== undefined) { result = await this.bashFactoryOverride(input.command, { cwd, + env: input.env, signal: controller.signal, customCommands, }); @@ -192,7 +195,7 @@ export class ShellWorker< defenseInDepth: { enabled: false }, executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES }, }); - result = await bash.exec(input.command, { cwd, signal: controller.signal }); + result = await bash.exec(input.command, { cwd, env: input.env, signal: controller.signal }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/computer/src/backends/worker-shell/worker-shell.test.ts b/packages/computer/src/backends/worker-shell/worker-shell.test.ts index 58ba2532..83941c16 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.test.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.test.ts @@ -31,7 +31,13 @@ type WireEvent = | { id: string; seq: number; name: "exit"; value: number }; interface FakeShellFetcher { - exec(input: { command: string; cwd?: string; id?: string; timeoutMs?: number }): Promise<{ + exec(input: { + command: string; + cwd?: string; + id?: string; + timeoutMs?: number; + env?: Record; + }): Promise<{ id: string; events: ReadableStream; }>; @@ -58,7 +64,13 @@ function framedStream(events: WireEvent[]): ReadableStream { } function fakeFetcher( - exec: (input: { command: string; cwd?: string; id?: string; timeoutMs?: number }) => { + exec: (input: { + command: string; + cwd?: string; + id?: string; + timeoutMs?: number; + env?: Record; + }) => { id: string; events: ReadableStream; }, @@ -148,6 +160,29 @@ describe("WorkerShellBackend", () => { expect(envelope.id).toBe("run-1"); }); + it("forwards per-execution environment variables to the fetcher", async () => { + let observedEnv: Record | undefined; + const fetcher = fakeFetcher((input) => { + observedEnv = input.env; + return { + id: "env", + events: framedStream([{ id: "env", seq: 1, name: "exit", value: 0 }]), + }; + }); + const backend = new WorkerShellBackend({ fetcher: () => fetcher }); + const handle = await backend.connect(); + const envelope = await handle.rpc.shell.exec({ + command: "printenv TOKEN", + env: { TOKEN: "secret", EMPTY: "" }, + }); + const reader = envelope.events.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" }); + }); + it("errors the stream on malformed execution frames", async () => { const fetcher = fakeFetcher(() => ({ id: "bad", diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index c3a1c554..3e65e67a 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -33,7 +33,13 @@ import { SHELL_RUNTIME_MODULES } from "./runtime-modules.js"; // implementation lives in ./entrypoint.ts; the backend consumes // it through the Fetcher the loader returns. export interface WorkerShellFetcher { - exec(input: { command: string; cwd?: string; id?: string; timeoutMs?: number }): Promise<{ + exec(input: { + command: string; + cwd?: string; + id?: string; + timeoutMs?: number; + env?: Record; + }): Promise<{ id: string; events: ReadableStream; }>; @@ -168,6 +174,7 @@ export class WorkerShellBackend implements WorkspaceBackend { cwd: input.cwd, id: input.id, timeoutMs: input.timeoutMs, + env: input.env, }); return { id: envelope.id, events: decodeFramedEvents(envelope.events) }; }, diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 2fa320f3..424ab8cb 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -65,6 +65,7 @@ export class WorkspaceRuntime { encoding: options.encoding, id: options.id, timeoutMs: options.timeoutMs, + env: options.env, }); return wrapCommandHandle(handle, backend); } diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index 7d8d010b..bba1b22a 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -105,6 +105,10 @@ export interface ExecOptions { // Omit to use the runner's default (typically 320_000). Pass 0 // to disable the timeout for this call. timeoutMs?: number; + // Environment variables inherited by this command only. Values + // override the backend's base environment without changing later + // executions. + env?: Record; // Backend selector. Omit to use the default backend (the first // one passed to the Workspace constructor); pass the id of // another configured backend to route this call there. @@ -177,6 +181,7 @@ export class WorkspaceShell { id: options.id, cwd: options.cwd, timeoutMs: options.timeoutMs, + env: options.env, }), (span, outcome) => { if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index 08c0c5b8..5c9fdbee 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -243,12 +243,41 @@ describe("Workspace backend selection", () => { expect(result).toMatchObject({ status: "completed", exitCode: 0 }); }); - it("rejects structured input for a non-callable command backend", async () => { - const backend = execBackend("command", () => {}); + it("forwards per-execution environment variables to command backends", async () => { + let receivedEnv: Record | undefined; + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec(input) { + receivedEnv = input.env; + const id = input.id ?? "env-command"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ id, seq: 1, name: "exit", value: 0 }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: async () => undefined, + }; + const backend: WorkspaceBackend = { + id: "command", + type: "fake", + async connect() { + return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined }; + }, + }; const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); - await expect( - ws.runtime.exec("true", { backend: "command", input: { a: 1 } }), - ).rejects.toThrow(/not callable/); + await drainExec( + await ws.runtime.exec("printenv TOKEN", { + backend: "command", + env: { TOKEN: "secret", EMPTY: "" }, + }), + ); + expect(receivedEnv).toEqual({ TOKEN: "secret", EMPTY: "" }); }); it("flushes incomplete trailing UTF-8 from command execution", async () => { diff --git a/packages/computerd/src/exec/runner.test.ts b/packages/computerd/src/exec/runner.test.ts index 149b54b9..f2beddc2 100644 --- a/packages/computerd/src/exec/runner.test.ts +++ b/packages/computerd/src/exec/runner.test.ts @@ -78,6 +78,31 @@ test("exec captures stdout and propagates exit code", async () => { } }); +test("per-execution env overrides the base env without leaking to later commands", async () => { + const { runner, dispose } = fixture({ env: { TOKEN: "base", BASE_ONLY: "yes" } }); + try { + const first = runner.exec('printf \'%s|%s|%s\' "$TOKEN" "$BASE_ONLY" "$EMPTY"', { + env: { TOKEN: "override", EMPTY: "" }, + }); + const firstEvents = await drain(first.events); + const firstStdout = firstEvents + .filter((event) => event.name === "stdout") + .map((event) => decode(event.value as Uint8Array)) + .join(""); + expect(firstStdout).toBe("override|yes|"); + + const second = runner.exec("printf '%s' \"$TOKEN\""); + const secondEvents = await drain(second.events); + const secondStdout = secondEvents + .filter((event) => event.name === "stdout") + .map((event) => decode(event.value as Uint8Array)) + .join(""); + expect(secondStdout).toBe("base"); + } finally { + dispose(); + } +}); + test("reusing a live id throws EEXEC_BUSY", async () => { const { runner, dispose } = fixture(); try { diff --git a/packages/rpc/src/interface.ts b/packages/rpc/src/interface.ts index 50b5ab79..874a3646 100644 --- a/packages/rpc/src/interface.ts +++ b/packages/rpc/src/interface.ts @@ -104,6 +104,8 @@ export interface ShellRPC { // 0 disables the timeout. Omit to use the runner's default // (typically 320_000). timeoutMs?: number; + // Environment variables inherited by this command only. + env?: Record; }): Promise<{ id: string; events: ReadableStream; diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts index 760b4cb2..a9da8084 100644 --- a/packages/rpc/src/server.ts +++ b/packages/rpc/src/server.ts @@ -33,7 +33,7 @@ import type { ExecEvent, ShellRPC, SyncRPC, WorkspaceRPC } from "./interface.js" export interface RunnerLike { exec( command: string, - options?: { id?: string; cwd?: string; timeoutMs?: number }, + options?: { id?: string; cwd?: string; timeoutMs?: number; env?: Record }, ): { id: string; events: ReadableStream; @@ -230,7 +230,13 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { untrackStub(this); } - async exec(input: { command: string; cwd?: string; id?: string; timeoutMs?: number }): Promise<{ + async exec(input: { + command: string; + cwd?: string; + id?: string; + timeoutMs?: number; + env?: Record; + }): Promise<{ id: string; events: ReadableStream; }> { @@ -238,6 +244,7 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, + env: input.env, }); } From 78c064c823c7406c3be0a7e96f9411f0d568b8bf Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:32 +0000 Subject: [PATCH 9/9] computer, rpc, computerd: harden JavaScript finalize and add command stdin Treat a JavaScript execution whose output stream closes without an exit frame as a failure rather than a silent exit 0 with no result: that state means a dropped frame write or a crashed isolate. Serialize the runner's frame writes on a chain the task awaits before closing, so a rejected write is caught instead of surfacing as an unhandled rejection. Thread standard input through the command path so container and worker shell backends receive it: the shell facade, the shell RPC contract and its server, the container runner spawn, and the worker shell entrypoint into just-bash. Reject structured input on the command path as well. Cover the new behavior: a no-exit-frame run settling as failed, stdin forwarded to command backends and to just-bash, stdin fed to a real spawned child, and structured input rejected on a command backend. --- .../worker-javascript.test.ts | 51 +++++++++++++++++++ .../worker-javascript/worker-javascript.ts | 29 +++++++++-- .../backends/worker-shell/entrypoint.test.ts | 18 ++++++- .../src/backends/worker-shell/entrypoint.ts | 22 +++++++- .../src/backends/worker-shell/worker-shell.ts | 2 + packages/computer/src/runtime/runtime.ts | 1 + packages/computer/src/shell.ts | 7 +++ packages/computer/src/workspace.test.ts | 40 +++++++++++++++ packages/computerd/src/exec/runner.test.ts | 17 +++++++ packages/computerd/src/exec/runner.ts | 9 +++- packages/computerd/src/exec/types.ts | 2 + packages/rpc/src/interface.ts | 2 + packages/rpc/src/server.ts | 10 +++- 13 files changed, 202 insertions(+), 8 deletions(-) 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 69e7b492..686070be 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -638,6 +638,57 @@ describe("WorkerJavaScriptBackend", () => { await handle.close(); }); + it("settles as failed when the output stream closes without an exit frame", async () => { + const db = new Database(new SQLiteTestStorage()); + initializeSchema(db, () => 0); + const fs = new WorkspaceFilesystem(db); + await fs.mkdir("/workspace", { recursive: true }); + const encoder = new TextEncoder(); + const backend = new WorkerJavaScriptBackend({ + loader: { + load() { + return { + getEntrypoint() { + return { + async evaluate( + _input: unknown, + host: { attachOutput(readable: ReadableStream): Promise }, + ) { + // Emit stdout, then close the stream with no result or + // exit frame, mimicking a dropped terminal write. + const readable = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ name: "stdout", b64: btoa("partial\n") })}\n`, + ), + ); + controller.close(); + }, + }); + await host.attachOutput(readable); + }, + }; + }, + }; + }, + }, + }); + const handle = await backend.connect({ + db, + fs, + git: undefined as never, + artifacts: undefined as never, + }); + const execution = await handle.exec({ id: "no-exit", source: "export default 1" }); + const events = []; + for await (const event of execution.events) events.push(event); + const exit = events.find((event) => event.name === "exit"); + expect(exit).toMatchObject({ name: "exit", value: 1 }); + expect(events.some((event) => event.name === "result")).toBe(false); + await handle.close(); + }); + it("aborts cooperative trusted-module calls at their deadline", async () => { const db = new Database(new SQLiteTestStorage()); initializeSchema(db, () => 0); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 476fff29..d0bdf43a 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -659,7 +659,23 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { ]); return; } - const exitCode = record.exitCode ?? 0; + // No exit frame means the runner never reported a terminal state: + // the output stream closed early, a frame write was dropped, or the + // isolate crashed. Settle as a failure rather than a silent exit 0 + // with no result. + if (record.exitCode === undefined) { + this.#finish(record, "failed", [ + { + id: record.id, + seq: record.events.length + 1, + name: "stderr", + value: new TextEncoder().encode("Execution ended without reporting a result.\n"), + }, + { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + ]); + return; + } + const exitCode = record.exitCode; const terminal: WorkspaceRuntimeEvent[] = []; if (exitCode === 0 && record.hasResult) { terminal.push({ @@ -1054,10 +1070,14 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { } return btoa(binary); }; + // Serialize writes on a chain so a rejected write (the host + // cancelled or the stream errored) is caught here rather than + // surfacing as an unhandled rejection inside the isolate. The + // task awaits writeChain before closing. + let writeChain = Promise.resolve(); const enqueue = (frame) => { - try { - writer.write(encoder.encode(JSON.stringify(frame) + "\\n")); - } catch {} + const bytes = encoder.encode(JSON.stringify(frame) + "\\n"); + writeChain = writeChain.then(() => writer.write(bytes)).catch(() => {}); }; let stdioBytes = 0; let stdioTruncated = false; @@ -1145,6 +1165,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); enqueue({ name: "exit", value: 1 }); } + await writeChain; try { await writer.close(); } catch {} diff --git a/packages/computer/src/backends/worker-shell/entrypoint.test.ts b/packages/computer/src/backends/worker-shell/entrypoint.test.ts index 96060f6b..c1fcd049 100644 --- a/packages/computer/src/backends/worker-shell/entrypoint.test.ts +++ b/packages/computer/src/backends/worker-shell/entrypoint.test.ts @@ -33,7 +33,12 @@ class TestShellWorker extends ShellWorker { env: E, bashFactory: ( command: string, - options: { cwd?: string; env?: Record; signal?: AbortSignal }, + options: { + cwd?: string; + env?: Record; + stdin?: Uint8Array; + signal?: AbortSignal; + }, ) => Promise<{ stdout: string; stderr: string; exitCode: number }>, ): TestShellWorker { const w = new TestShellWorker(undefined as never, env as never); @@ -203,6 +208,17 @@ describe("ShellWorker", () => { expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" }); }); + it("forwards per-execution stdin bytes to Bash", async () => { + let observedStdin: Uint8Array | undefined; + const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => { + observedStdin = options.stdin; + return { stdout: "", stderr: "", exitCode: 0 }; + }); + const bytes = new TextEncoder().encode("piped"); + await drain((await worker.exec({ command: "cat", stdin: bytes })).events); + expect(observedStdin).toEqual(bytes); + }); + it("getExec without a prior exec throws ENOENT", async () => { const worker = new TestShellWorker(undefined as never, fakeEnv() as never); await expect(worker.getExec({ id: "missing" })).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/packages/computer/src/backends/worker-shell/entrypoint.ts b/packages/computer/src/backends/worker-shell/entrypoint.ts index 354923da..690d6a91 100644 --- a/packages/computer/src/backends/worker-shell/entrypoint.ts +++ b/packages/computer/src/backends/worker-shell/entrypoint.ts @@ -31,6 +31,7 @@ export interface ExecInput { id?: string; timeoutMs?: number; env?: Record; + stdin?: Uint8Array; } export interface ShellWorkerOptions { @@ -107,6 +108,7 @@ export class ShellWorker< options: { cwd?: string; env?: Record; + stdin?: Uint8Array; signal?: AbortSignal; customCommands: CustomCommand[]; }, @@ -174,6 +176,7 @@ export class ShellWorker< result = await this.bashFactoryOverride(input.command, { cwd, env: input.env, + stdin: input.stdin, signal: controller.signal, customCommands, }); @@ -195,7 +198,14 @@ export class ShellWorker< defenseInDepth: { enabled: false }, executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES }, }); - result = await bash.exec(input.command, { cwd, env: input.env, signal: controller.signal }); + result = await bash.exec(input.command, { + cwd, + env: input.env, + ...(input.stdin !== undefined + ? { stdin: latin1FromBytes(input.stdin), stdinKind: "bytes" as const } + : {}), + signal: controller.signal, + }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -263,6 +273,16 @@ function framedStream(events: WireEvent[]): ReadableStream { }); } +// just-bash's `stdin` with `stdinKind: "bytes"` carries each byte as one +// latin1 char. Pack the caller's bytes into that shape. +function latin1FromBytes(bytes: Uint8Array): string { + let result = ""; + for (let index = 0; index < bytes.length; index += 1) { + result += String.fromCharCode(bytes[index]); + } + return result; +} + function createShellError(code: string, message: string): Error & { code: string } { const error = new Error(message) as Error & { code: string }; error.name = "ShellWorkerError"; diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index 3e65e67a..b78c9abc 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -39,6 +39,7 @@ export interface WorkerShellFetcher { id?: string; timeoutMs?: number; env?: Record; + stdin?: Uint8Array; }): Promise<{ id: string; events: ReadableStream; @@ -175,6 +176,7 @@ export class WorkerShellBackend implements WorkspaceBackend { id: input.id, timeoutMs: input.timeoutMs, env: input.env, + stdin: input.stdin, }); return { id: envelope.id, events: decodeFramedEvents(envelope.events) }; }, diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 424ab8cb..b426a214 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -66,6 +66,7 @@ export class WorkspaceRuntime { id: options.id, timeoutMs: options.timeoutMs, env: options.env, + stdin: options.stdin, }); return wrapCommandHandle(handle, backend); } diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index bba1b22a..a199281c 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -109,6 +109,9 @@ export interface ExecOptions { // override the backend's base environment without changing later // executions. env?: Record; + // Standard input fed to the command. Bytes, or a string encoded + // as UTF-8. + stdin?: Uint8Array | string; // Backend selector. Omit to use the default backend (the first // one passed to the Workspace constructor); pass the id of // another configured backend to route this call there. @@ -182,6 +185,10 @@ export class WorkspaceShell { cwd: options.cwd, timeoutMs: options.timeoutMs, env: options.env, + stdin: + typeof options.stdin === "string" + ? new TextEncoder().encode(options.stdin) + : options.stdin, }), (span, outcome) => { if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index 5c9fdbee..467d7fee 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -243,6 +243,46 @@ describe("Workspace backend selection", () => { expect(result).toMatchObject({ status: "completed", exitCode: 0 }); }); + it("rejects structured input for a non-callable command backend", async () => { + const backend = execBackend("command", () => {}); + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + await expect(ws.runtime.exec("true", { backend: "command", input: { a: 1 } })).rejects.toThrow( + /not callable/, + ); + }); + + it("forwards per-execution stdin to command backends", async () => { + let receivedStdin: Uint8Array | undefined; + const shell: import("@cloudflare/computer-rpc").ShellRPC = { + async exec(input) { + receivedStdin = input.stdin; + const id = input.id ?? "stdin-command"; + return { + id, + events: new ReadableStream({ + start(controller) { + controller.enqueue({ id, seq: 1, name: "exit", value: 0 }); + controller.close(); + }, + }), + }; + }, + getExec: () => Promise.reject(new Error("not used")), + killExec: () => Promise.reject(new Error("not used")), + disposeExec: async () => undefined, + }; + const backend: WorkspaceBackend = { + id: "command", + type: "fake", + async connect() { + return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined }; + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + await drainExec(await ws.runtime.exec("cat", { backend: "command", stdin: "piped" })); + expect(receivedStdin).toEqual(new TextEncoder().encode("piped")); + }); + it("forwards per-execution environment variables to command backends", async () => { let receivedEnv: Record | undefined; const shell: import("@cloudflare/computer-rpc").ShellRPC = { diff --git a/packages/computerd/src/exec/runner.test.ts b/packages/computerd/src/exec/runner.test.ts index f2beddc2..97be8ec9 100644 --- a/packages/computerd/src/exec/runner.test.ts +++ b/packages/computerd/src/exec/runner.test.ts @@ -103,6 +103,23 @@ test("per-execution env overrides the base env without leaking to later commands } }); +test("feeds per-execution stdin to the child and closes it", async () => { + const { runner, dispose } = fixture(); + try { + const handle = runner.exec("cat", { stdin: new TextEncoder().encode("piped-input") }); + const events = await drain(handle.events); + const stdout = events + .filter((event) => event.name === "stdout") + .map((event) => decode(event.value as Uint8Array)) + .join(""); + const exit = events.find((event) => event.name === "exit"); + expect(stdout).toBe("piped-input"); + expect(exit?.value).toBe(0); + } finally { + dispose(); + } +}); + test("reusing a live id throws EEXEC_BUSY", async () => { const { runner, dispose } = fixture(); try { diff --git a/packages/computerd/src/exec/runner.ts b/packages/computerd/src/exec/runner.ts index ab7fb148..76ff977e 100644 --- a/packages/computerd/src/exec/runner.ts +++ b/packages/computerd/src/exec/runner.ts @@ -144,8 +144,15 @@ export class Runner { const wrapped = cwd !== undefined ? `cd ${shellQuote(cwd)} && ${command}` : command; const child = spawn("/bin/sh", ["-c", wrapped], { env, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], }); + if (options.stdin !== undefined && child.stdin) { + // Feed the caller's bytes then close so the child sees EOF. + // Ignore write/EPIPE errors: a command that never reads stdin + // (or exits first) must not fail the run. + child.stdin.on("error", () => {}); + child.stdin.end(Buffer.from(options.stdin)); + } const log = createLog(this.db, id, { maxBytes: this.opts.logMaxBytes, now: this.opts.now, diff --git a/packages/computerd/src/exec/types.ts b/packages/computerd/src/exec/types.ts index 49fc2d99..647ee0a7 100644 --- a/packages/computerd/src/exec/types.ts +++ b/packages/computerd/src/exec/types.ts @@ -37,6 +37,8 @@ export interface ExecOptions { timeoutMs?: number; // Inherited by the child. Merged on top of the runner's base env. env?: Record; + // Standard input bytes written to the child's stdin, then closed. + stdin?: Uint8Array; } export interface RunnerOptions { diff --git a/packages/rpc/src/interface.ts b/packages/rpc/src/interface.ts index 874a3646..7eb5bca0 100644 --- a/packages/rpc/src/interface.ts +++ b/packages/rpc/src/interface.ts @@ -106,6 +106,8 @@ export interface ShellRPC { timeoutMs?: number; // Environment variables inherited by this command only. env?: Record; + // Standard input bytes fed to the command. + stdin?: Uint8Array; }): Promise<{ id: string; events: ReadableStream; diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts index a9da8084..b07915e3 100644 --- a/packages/rpc/src/server.ts +++ b/packages/rpc/src/server.ts @@ -33,7 +33,13 @@ import type { ExecEvent, ShellRPC, SyncRPC, WorkspaceRPC } from "./interface.js" export interface RunnerLike { exec( command: string, - options?: { id?: string; cwd?: string; timeoutMs?: number; env?: Record }, + options?: { + id?: string; + cwd?: string; + timeoutMs?: number; + env?: Record; + stdin?: Uint8Array; + }, ): { id: string; events: ReadableStream; @@ -236,6 +242,7 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { id?: string; timeoutMs?: number; env?: Record; + stdin?: Uint8Array; }): Promise<{ id: string; events: ReadableStream; @@ -245,6 +252,7 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { cwd: input.cwd, timeoutMs: input.timeoutMs, env: input.env, + stdin: input.stdin, }); }