From 1662c193e9b64f1905a060c6c92540d72be83bb0 Mon Sep 17 00:00:00 2001 From: scuffi Date: Fri, 31 Jul 2026 15:22:18 +0100 Subject: [PATCH] support per-execution environment variables --- docs/05_runtime_interface.md | 3 +- .../src/backends/worker/entrypoint.test.ts | 15 ++++- .../src/backends/worker/entrypoint.ts | 9 ++- .../src/backends/worker/worker.test.ts | 36 +++++++++++- .../computer/src/backends/worker/worker.ts | 9 ++- packages/computer/src/runtime/runtime.ts | 4 ++ packages/computer/src/runtime/types.ts | 2 + packages/computer/src/shell.ts | 4 ++ packages/computer/src/workspace.test.ts | 58 +++++++++++++++++++ packages/computerd/src/exec/runner.test.ts | 25 ++++++++ packages/rpc/src/interface.ts | 2 + packages/rpc/src/server.ts | 11 +++- .../rpc/tests/shell-and-composite.test.ts | 8 ++- 13 files changed, 177 insertions(+), 9 deletions(-) diff --git a/docs/05_runtime_interface.md b/docs/05_runtime_interface.md index 510e664..f4f547b 100644 --- a/docs/05_runtime_interface.md +++ b/docs/05_runtime_interface.md @@ -31,6 +31,7 @@ interface WorkspaceRuntimeExecOptions { encoding?: "utf8"; input?: WorkspaceRuntimeValue; timeoutMs?: number; + env?: Record; } interface WorkspaceRuntimeExecHandle extends ReadableStream { @@ -42,7 +43,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); @@ -189,6 +189,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/entrypoint.ts b/packages/computer/src/backends/worker/entrypoint.ts index 9bfff82..413ee5a 100644 --- a/packages/computer/src/backends/worker/entrypoint.ts +++ b/packages/computer/src/backends/worker/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,11 @@ 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/worker.test.ts b/packages/computer/src/backends/worker/worker.test.ts index d199be5..92c603c 100644 --- a/packages/computer/src/backends/worker/worker.test.ts +++ b/packages/computer/src/backends/worker/worker.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; }, @@ -184,6 +196,26 @@ describe("WorkerBackend", () => { expect(observed?.id).toBe("fixed"); }); + it("forwards per-execution environment variables to the worker 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 ws = new Workspace({ + storage: new SQLiteTestStorage() as never, + backends: [new WorkerBackend({ fetcher: () => fetcher })], + }); + const execution = await ws.runtime.exec("printenv TOKEN", { + env: { TOKEN: "secret", EMPTY: "" }, + }); + await execution.result(); + expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" }); + }); + it("plumbs through Workspace.shell.exec end-to-end", async () => { // Construct WorkerBackend as the sole backend of a Workspace // and exercise the public shell.exec entry point. Pushes and diff --git a/packages/computer/src/backends/worker/worker.ts b/packages/computer/src/backends/worker/worker.ts index 154c17f..de7cf93 100644 --- a/packages/computer/src/backends/worker/worker.ts +++ b/packages/computer/src/backends/worker/worker.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 WorkerBackend 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 e7a9895..4ea60e5 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -57,10 +57,14 @@ export class WorkspaceRuntime { encoding: options.encoding, id: options.id, timeoutMs: options.timeoutMs, + env: options.env, }); return wrapCommandHandle(handle, backend); } + if (options.env !== undefined) { + throw new Error(`Backend ${JSON.stringify(backend)} does not accept environment variables.`); + } const runtime = await this.#options.moduleHandle(backend); const envelope = await runtime.exec({ id: options.id, diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 7964de2..2271787 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -109,6 +109,8 @@ export interface WorkspaceRuntimeExecOptions encoding?: E; input?: WorkspaceRuntimeValue; timeoutMs?: number; + /** Environment variables for command backends. Module backends reject this option. */ + env?: Record; } export interface WorkspaceRuntimeGetOptions { diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index 7d8d010..ed147b6 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -105,6 +105,9 @@ 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 +180,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 e775d1f..9a8eff1 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -242,6 +242,43 @@ describe("Workspace backend selection", () => { expect(result).toMatchObject({ status: "completed", exitCode: 0 }); }); + 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 () => { const id = "utf8-command"; const shell: import("@cloudflare/computer-rpc").ShellRPC = { @@ -425,6 +462,27 @@ describe("Workspace backend selection", () => { ]); }); + it("rejects process environment variables for module backends", async () => { + let connected = false; + const backend: WorkspaceModuleBackend = { + protocol: "module", + id: "module", + type: "test-module", + async connect() { + connected = true; + throw new Error("module backend should not connect"); + }, + }; + const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); + await expect( + ws.runtime.exec("export default 42", { + backend: "module", + env: { TOKEN: "secret" }, + }), + ).rejects.toThrow(/does not accept environment variables/); + expect(connected).toBe(false); + }); + it("throws on an unknown backend id", async () => { const ws = new Workspace({ storage: makeStorage(), diff --git a/packages/computerd/src/exec/runner.test.ts b/packages/computerd/src/exec/runner.test.ts index 149b54b..f2beddc 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 50b5ab7..874a364 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 760b4cb..a9da808 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, }); } diff --git a/packages/rpc/tests/shell-and-composite.test.ts b/packages/rpc/tests/shell-and-composite.test.ts index bc964df..a286947 100644 --- a/packages/rpc/tests/shell-and-composite.test.ts +++ b/packages/rpc/tests/shell-and-composite.test.ts @@ -35,6 +35,7 @@ interface ExecRecord { id: string; command: string; cwd?: string; + env?: Record; events: ExecEvent[]; killed?: { signal: string }; disposed?: boolean; @@ -68,6 +69,7 @@ function makeFakeRunner(): FakeRunner { id, command, cwd: options?.cwd, + env: options?.env, events: [ { id, @@ -158,7 +160,10 @@ describe("ShellRPC over a real WebSocket", () => { // `client.exec(...)` lands on it directly. createWorkspaceClient // proxies all property access through; capnweb routes by name. // biome-ignore lint/suspicious/noExplicitAny: client targets ShellRPC for this harness, not WorkspaceRPC - const handle = await (client as any).exec({ command: "echo hi" }); + const handle = await (client as any).exec({ + command: "echo hi", + env: { TOKEN: "secret", EMPTY: "" }, + }); const events = await drainExec(handle.events); expect(events).toHaveLength(2); expect(events[0]?.name).toBe("stdout"); @@ -168,6 +173,7 @@ describe("ShellRPC over a real WebSocket", () => { // Server-side: the runner saw the call. const rec = harness.runner.records.get(handle.id); expect(rec?.command).toBe("echo hi"); + expect(rec?.env).toEqual({ TOKEN: "secret", EMPTY: "" }); } finally { await client.close(); }