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 { + 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; +} 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..686070be 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,36 @@ function throwingLoader(message: string) { }; } +// 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 { + 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(); + 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", () => { it("requires a host event-lifetime hook", async () => { const backend = new ProductionWorkerJavaScriptBackend({ loader: throwingLoader("unused") }); @@ -159,7 +189,15 @@ 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; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluateResult(host, "result-too-large"), + }; }, })); const workspace = new Workspace({ @@ -389,7 +427,15 @@ describe("WorkerJavaScriptBackend", () => { load() { return { getEntrypoint() { - return { evaluate: () => evaluation }; + return { + evaluate: ( + _input: unknown, + host: { + assertResult(value: unknown): Promise; + attachOutput(readable: ReadableStream): Promise; + }, + ) => evaluation.then((outcome) => evaluateResult(host, outcome.result)), + }; }, }; }, @@ -433,10 +479,14 @@ describe("WorkerJavaScriptBackend", () => { return { evaluate( _input: unknown, - host: { call(name: string, args: string): Promise }, + 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 Promise.resolve({ result: 1 }); + return evaluateResult(host, 1); }, }; }, @@ -466,6 +516,179 @@ 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("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("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); @@ -496,7 +719,6 @@ describe("WorkerJavaScriptBackend", () => { host: { call(name: string, args: string): Promise }, ) { await host.call("trusted/ws:test.call", JSON.stringify(["run"])); - return { result: 1 }; }, }; }, @@ -601,7 +823,15 @@ describe("WorkerJavaScriptBackend", () => { load() { return { getEntrypoint() { - return { evaluate: () => evaluation }; + return { + evaluate: ( + _input: unknown, + 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 f0bd31a7..d0bdf43a 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 { @@ -32,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; @@ -72,8 +72,7 @@ type ResolvedWorkerJavaScriptBackendOptions = Required< | "maxStdinBytes" | "maxEnvBytes" | "maxResultBytes" - | "maxLogBytes" - | "maxLogEvents" + | "maxStdioBytes" | "maxCapabilityBytes" | "maxHostCallMs" | "maxConcurrentCapabilityCalls" @@ -97,17 +96,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; } @@ -132,6 +126,9 @@ interface ExecutionRecord { finalization?: Promise; admitted?: boolean; persistenceFailed?: boolean; + result?: WorkspaceRuntimeValue; + hasResult?: boolean; + exitCode?: number; } export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { @@ -153,8 +150,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( @@ -196,8 +192,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, @@ -396,6 +391,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { maxCalls: this.#options.maxCapabilityCalls, maxTotalRequestBytes: this.#options.maxCapabilityRequestBytes, maxTotalResponseBytes: this.#options.maxCapabilityResponseBytes, + maxResultBytes: this.#options.maxResultBytes, + onAttachOutput: (readable) => this.#pumpFrames(record, readable), }); record.bridge = bridge; record.control = startJavaScriptExecution({ @@ -413,19 +410,16 @@ 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, - maxResultBytes: this.#options.maxResultBytes, + maxStdioBytes: this.#options.maxStdioBytes, maxSourceBytes: this.#options.maxSourceBytes, - onComplete: (outcome) => this.#complete(record, outcome), + 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, { - error: error instanceof Error ? error.message : String(error), - }); + await this.#finalize(record, error instanceof Error ? error.message : String(error)); } return { id, events: this.#stream(record) }; } finally { @@ -574,21 +568,67 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { return record; } - #complete( - record: ExecutionRecord, - outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, - ): 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) { + 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); + } + } 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, outcome); + const finalization = this.#finalizeOnce(record, errorMessage); record.finalization = finalization; return finalization; } - async #completeOnce( - record: ExecutionRecord, - outcome: { result?: unknown; logs?: RuntimeLogEntry[]; error?: string }, - ) { + async #finalizeOnce(record: ExecutionRecord, errorMessage?: string) { try { await record.bridge?.cancelAndDrain(); } catch (error) { @@ -605,63 +645,53 @@ 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", [ - { - 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; - } - 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 }, - ]); - } - } 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; } + // 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({ + 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") { @@ -886,24 +916,14 @@ function startJavaScriptExecution(options: { globalOutbound: Fetcher | null; compatibilityDate: string; compatibilityFlags: string[]; - maxLogBytes: number; - maxLogEvents: number; - maxResultBytes: number; + maxStdioBytes: number; maxSourceBytes: number; - onComplete(outcome: { - result?: unknown; - logs?: RuntimeLogEntry[]; - error?: string; - }): void | Promise; + onComplete(): void | Promise; + onError(message: string): void | Promise; }): ActiveControl { const modules = { ...options.modules, - "workspace-runtime-runner.js": runtimeWorkerModule( - options.entryName, - options.maxLogBytes, - options.maxLogEvents, - options.maxResultBytes, - ), + "workspace-runtime-runner.js": runtimeWorkerModule(options.entryName, options.maxStdioBytes), }; assertLoaderGraph(modules, options.maxSourceBytes); const worker = options.loader.load({ @@ -936,41 +956,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 (outcome) => { + // 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); dispose(); - await options.onComplete(outcome); + await options.onComplete(); }) .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") + 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() { @@ -983,12 +1006,7 @@ function startJavaScriptExecution(options: { }; } -function runtimeWorkerModule( - entryName: string, - maxLogBytes: number, - maxLogEvents: number, - maxResultBytes: number, -) { +function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { return ` import { WorkerEntrypoint } from "cloudflare:workers"; import { install } from "workspace-capabilities.js"; @@ -1041,27 +1059,38 @@ function runtimeWorkerModule( globalThis.process.stdin = stdin; } catch {} } - const logs = []; const encoder = new TextEncoder(); - let logBytes = 0; - let logEvents = 0; - let logsTruncated = false; - const record = (stream, text) => { - if (logsTruncated) return; - if (logEvents >= ${maxLogEvents - 1}) { - logs.push({ stream, text: "...[logs truncated]" }); - logsTruncated = true; - return; + 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); + }; + // 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) => { + const bytes = encoder.encode(JSON.stringify(frame) + "\\n"); + writeChain = writeChain.then(() => writer.write(bytes)).catch(() => {}); + }; + let stdioBytes = 0; + let stdioTruncated = false; + const record = (stream, text) => { + 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; + enqueue({ name: stream, b64: toBase64(text) }); + 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); @@ -1074,10 +1103,10 @@ function runtimeWorkerModule( head = head.slice(0, -1); } } - logs.push({ stream, text: partial + "...[logs truncated]" }); - logBytes += head.byteLength + marker.byteLength; + enqueue({ name: stream, b64: toBase64(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); @@ -1104,29 +1133,43 @@ function runtimeWorkerModule( globalThis.process.stderr = nextProcess.stderr; } catch {} } + const truncate = (message) => { + const bytes = encoder.encode(message); + let prefix = bytes.slice(0, ${maxStdioBytes - 1}); + while (prefix.byteLength > 0) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(prefix); + } catch { + prefix = prefix.slice(0, -1); + } + } + 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" ? await module.default(input) : module.default ?? null; - if (encoder.encode(JSON.stringify(result)).byteLength > ${maxResultBytes}) { - throw new Error("Workspace runtime result exceeds ${maxResultBytes} bytes."); - } - return { result, logs }; + const value = result ?? null; + await host.assertResult(value); + enqueue({ name: "result", value }); + enqueue({ name: "exit", value: 0 }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const bytes = encoder.encode(message); - let prefix = bytes.slice(0, ${maxLogBytes}); - while (prefix.byteLength > 0) { - try { - return { error: new TextDecoder("utf-8", { fatal: true }).decode(prefix), logs }; - } catch { - prefix = prefix.slice(0, -1); - } - } - return { error: "", logs }; + enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); + enqueue({ name: "exit", value: 1 }); } + await writeChain; + try { + await writer.close(); + } catch {} + await drained; } } `; diff --git a/packages/computer/src/backends/worker-shell/entrypoint.test.ts b/packages/computer/src/backends/worker-shell/entrypoint.test.ts index cde7eac9..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; 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); @@ -190,6 +195,30 @@ 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("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 84e8a423..690d6a91 100644 --- a/packages/computer/src/backends/worker-shell/entrypoint.ts +++ b/packages/computer/src/backends/worker-shell/entrypoint.ts @@ -30,6 +30,8 @@ export interface ExecInput { cwd?: string; id?: string; timeoutMs?: number; + env?: Record; + stdin?: Uint8Array; } export interface ShellWorkerOptions { @@ -105,6 +107,8 @@ export class ShellWorker< command: string, options: { cwd?: string; + env?: Record; + stdin?: Uint8Array; signal?: AbortSignal; customCommands: CustomCommand[]; }, @@ -171,6 +175,8 @@ export class ShellWorker< if (this.bashFactoryOverride !== undefined) { result = await this.bashFactoryOverride(input.command, { cwd, + env: input.env, + stdin: input.stdin, signal: controller.signal, customCommands, }); @@ -192,7 +198,14 @@ 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, + ...(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); @@ -260,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.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..b78c9abc 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -33,7 +33,14 @@ 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; + stdin?: Uint8Array; + }): Promise<{ id: string; events: ReadableStream; }>; @@ -168,6 +175,8 @@ export class WorkerShellBackend implements WorkspaceBackend { cwd: input.cwd, 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/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..552e28f0 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,8 @@ export class WorkspaceRuntimeBridge extends RpcTarget { readonly #maxCalls: number; readonly #maxTotalRequestBytes: number; readonly #maxTotalResponseBytes: number; + readonly #maxResultBytes: number; + readonly #onAttachOutput?: (readable: ReadableStream) => Promise; readonly #inFlight = new Set>(); readonly #abortControllers = new Set(); #cancelled = false; @@ -40,6 +42,8 @@ export class WorkspaceRuntimeBridge extends RpcTarget { maxCalls?: number; maxTotalRequestBytes?: number; maxTotalResponseBytes?: number; + maxResultBytes?: number; + onAttachOutput?: (readable: ReadableStream) => Promise; } = {}, ) { super(); @@ -55,6 +59,30 @@ 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; + 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. + // 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 { diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 2fa320f3..b426a214 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -65,6 +65,8 @@ export class WorkspaceRuntime { encoding: options.encoding, 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 7d8d010b..a199281c 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -105,6 +105,13 @@ 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; + // 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. @@ -177,6 +184,11 @@ export class WorkspaceShell { id: options.id, 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 08c0c5b8..467d7fee 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -246,9 +246,78 @@ describe("Workspace backend selection", () => { 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/); + 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 = { + 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 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/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index 6df9cc11..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: { @@ -140,6 +139,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 +235,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..61c8b63f 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: ` @@ -216,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 () => { @@ -234,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 () => { diff --git a/packages/computerd/src/exec/runner.test.ts b/packages/computerd/src/exec/runner.test.ts index 149b54b9..97be8ec9 100644 --- a/packages/computerd/src/exec/runner.test.ts +++ b/packages/computerd/src/exec/runner.test.ts @@ -78,6 +78,48 @@ 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("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 50b5ab79..7eb5bca0 100644 --- a/packages/rpc/src/interface.ts +++ b/packages/rpc/src/interface.ts @@ -104,6 +104,10 @@ 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; + // 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 760b4cb2..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 }, + options?: { + id?: string; + cwd?: string; + timeoutMs?: number; + env?: Record; + stdin?: Uint8Array; + }, ): { id: string; events: ReadableStream; @@ -230,7 +236,14 @@ 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; + stdin?: Uint8Array; + }): Promise<{ id: string; events: ReadableStream; }> { @@ -238,6 +251,8 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, + env: input.env, + stdin: input.stdin, }); }