Skip to content

Commit 7171371

Browse files
committed
support per-execution environment variables
1 parent f1cf132 commit 7171371

13 files changed

Lines changed: 177 additions & 9 deletions

File tree

docs/05_runtime_interface.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface WorkspaceRuntimeExecOptions {
3131
encoding?: "utf8";
3232
input?: WorkspaceRuntimeValue;
3333
timeoutMs?: number;
34+
env?: Record<string, string>;
3435
}
3536

3637
interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEvent> {
@@ -42,7 +43,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEven
4243
}
4344
```
4445

45-
`input` is accepted by structured module backends and rejected by command backends. `cwd` is the command working directory or the base for durable relative module imports. A handle is single-consumer: call `result()` or consume its event stream, not both. Repeated `result()` calls return the same promise. `backend` records the resolved backend needed for later reattachment.
46+
`input` is accepted by structured module backends and rejected by command backends. `env` is accepted by command backends and rejected by module backends; its values override that command's inherited environment without changing later executions. `cwd` is the command working directory or the base for durable relative module imports. A handle is single-consumer: call `result()` or consume its event stream, not both. Repeated `result()` calls return the same promise. `backend` records the resolved backend needed for later reattachment.
4647

4748
## Results
4849

packages/computer/src/backends/worker/entrypoint.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class TestShellWorker extends ShellWorker {
3232
env: E,
3333
bashFactory: (
3434
command: string,
35-
options: { cwd?: string; signal?: AbortSignal },
35+
options: { cwd?: string; env?: Record<string, string>; signal?: AbortSignal },
3636
) => Promise<{ stdout: string; stderr: string; exitCode: number }>,
3737
): TestShellWorker {
3838
const w = new TestShellWorker(undefined as never, env as never);
@@ -189,6 +189,19 @@ describe("ShellWorker", () => {
189189
expect(observedCwd).toBe("/workspace/src");
190190
});
191191

192+
it("forwards per-execution environment variables to Bash", async () => {
193+
let observedEnv: Record<string, string> | undefined;
194+
const worker = TestShellWorker.withFakeBash(fakeEnv(), async (_command, options) => {
195+
observedEnv = options.env;
196+
return { stdout: "", stderr: "", exitCode: 0 };
197+
});
198+
await drain(
199+
(await worker.exec({ command: "printenv TOKEN", env: { TOKEN: "secret", EMPTY: "" } }))
200+
.events,
201+
);
202+
expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" });
203+
});
204+
192205
it("getExec without a prior exec throws ENOENT", async () => {
193206
const worker = new TestShellWorker(undefined as never, fakeEnv() as never);
194207
await expect(worker.getExec({ id: "missing" })).rejects.toMatchObject({ code: "ENOENT" });

packages/computer/src/backends/worker/entrypoint.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ExecInput {
3030
cwd?: string;
3131
id?: string;
3232
timeoutMs?: number;
33+
env?: Record<string, string>;
3334
}
3435

3536
export interface ShellWorkerOptions {
@@ -110,6 +111,7 @@ export class ShellWorker<
110111
command: string,
111112
options: {
112113
cwd?: string;
114+
env?: Record<string, string>;
113115
signal?: AbortSignal;
114116
customCommands: CustomCommand[];
115117
},
@@ -176,6 +178,7 @@ export class ShellWorker<
176178
if (this.bashFactoryOverride !== undefined) {
177179
result = await this.bashFactoryOverride(input.command, {
178180
cwd,
181+
env: input.env,
179182
signal: controller.signal,
180183
customCommands,
181184
});
@@ -197,7 +200,11 @@ export class ShellWorker<
197200
defenseInDepth: { enabled: false },
198201
executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES },
199202
});
200-
result = await bash.exec(input.command, { cwd, signal: controller.signal });
203+
result = await bash.exec(input.command, {
204+
cwd,
205+
env: input.env,
206+
signal: controller.signal,
207+
});
201208
}
202209
} catch (error) {
203210
const message = error instanceof Error ? error.message : String(error);

packages/computer/src/backends/worker/worker.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ type WireEvent =
3131
| { id: string; seq: number; name: "exit"; value: number };
3232

3333
interface FakeShellFetcher {
34-
exec(input: { command: string; cwd?: string; id?: string; timeoutMs?: number }): Promise<{
34+
exec(input: {
35+
command: string;
36+
cwd?: string;
37+
id?: string;
38+
timeoutMs?: number;
39+
env?: Record<string, string>;
40+
}): Promise<{
3541
id: string;
3642
events: ReadableStream<Uint8Array>;
3743
}>;
@@ -58,7 +64,13 @@ function framedStream(events: WireEvent[]): ReadableStream<Uint8Array> {
5864
}
5965

6066
function fakeFetcher(
61-
exec: (input: { command: string; cwd?: string; id?: string; timeoutMs?: number }) => {
67+
exec: (input: {
68+
command: string;
69+
cwd?: string;
70+
id?: string;
71+
timeoutMs?: number;
72+
env?: Record<string, string>;
73+
}) => {
6274
id: string;
6375
events: ReadableStream<Uint8Array>;
6476
},
@@ -184,6 +196,26 @@ describe("WorkerBackend", () => {
184196
expect(observed?.id).toBe("fixed");
185197
});
186198

199+
it("forwards per-execution environment variables to the worker fetcher", async () => {
200+
let observedEnv: Record<string, string> | undefined;
201+
const fetcher = fakeFetcher((input) => {
202+
observedEnv = input.env;
203+
return {
204+
id: "env",
205+
events: framedStream([{ id: "env", seq: 1, name: "exit", value: 0 }]),
206+
};
207+
});
208+
const ws = new Workspace({
209+
storage: new SQLiteTestStorage() as never,
210+
backends: [new WorkerBackend({ fetcher: () => fetcher })],
211+
});
212+
const execution = await ws.runtime.exec("printenv TOKEN", {
213+
env: { TOKEN: "secret", EMPTY: "" },
214+
});
215+
await execution.result();
216+
expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" });
217+
});
218+
187219
it("plumbs through Workspace.shell.exec end-to-end", async () => {
188220
// Construct WorkerBackend as the sole backend of a Workspace
189221
// and exercise the public shell.exec entry point. Pushes and

packages/computer/src/backends/worker/worker.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ import { SHELL_RUNTIME_MODULES } from "./runtime-modules.js";
3333
// implementation lives in ./entrypoint.ts; the backend consumes
3434
// it through the Fetcher the loader returns.
3535
export interface WorkerShellFetcher {
36-
exec(input: { command: string; cwd?: string; id?: string; timeoutMs?: number }): Promise<{
36+
exec(input: {
37+
command: string;
38+
cwd?: string;
39+
id?: string;
40+
timeoutMs?: number;
41+
env?: Record<string, string>;
42+
}): Promise<{
3743
id: string;
3844
events: ReadableStream<Uint8Array>;
3945
}>;
@@ -168,6 +174,7 @@ export class WorkerBackend implements WorkspaceBackend {
168174
cwd: input.cwd,
169175
id: input.id,
170176
timeoutMs: input.timeoutMs,
177+
env: input.env,
171178
});
172179
return { id: envelope.id, events: decodeFramedEvents(envelope.events) };
173180
},

packages/computer/src/runtime/runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ export class WorkspaceRuntime {
5757
encoding: options.encoding,
5858
id: options.id,
5959
timeoutMs: options.timeoutMs,
60+
env: options.env,
6061
});
6162
return wrapCommandHandle(handle, backend);
6263
}
6364

65+
if (options.env !== undefined) {
66+
throw new Error(`Backend ${JSON.stringify(backend)} does not accept environment variables.`);
67+
}
6468
const runtime = await this.#options.moduleHandle(backend);
6569
const envelope = await runtime.exec({
6670
id: options.id,

packages/computer/src/runtime/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export interface WorkspaceRuntimeExecOptions<E extends ExecEncoding = undefined>
109109
encoding?: E;
110110
input?: WorkspaceRuntimeValue;
111111
timeoutMs?: number;
112+
/** Environment variables for command backends. Module backends reject this option. */
113+
env?: Record<string, string>;
112114
}
113115

114116
export interface WorkspaceRuntimeGetOptions<E extends ExecEncoding = undefined> {

packages/computer/src/shell.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ export interface ExecOptions<E extends ExecEncoding = undefined> {
105105
// Omit to use the runner's default (typically 320_000). Pass 0
106106
// to disable the timeout for this call.
107107
timeoutMs?: number;
108+
// Environment variables inherited by this command only. Values override
109+
// the backend's base environment without changing later executions.
110+
env?: Record<string, string>;
108111
// Backend selector. Omit to use the default backend (the first
109112
// one passed to the Workspace constructor); pass the id of
110113
// another configured backend to route this call there.
@@ -177,6 +180,7 @@ export class WorkspaceShell {
177180
id: options.id,
178181
cwd: options.cwd,
179182
timeoutMs: options.timeoutMs,
183+
env: options.env,
180184
}),
181185
(span, outcome) => {
182186
if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id);

packages/computer/src/workspace.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,43 @@ describe("Workspace backend selection", () => {
242242
expect(result).toMatchObject({ status: "completed", exitCode: 0 });
243243
});
244244

245+
it("forwards per-execution environment variables to command backends", async () => {
246+
let receivedEnv: Record<string, string> | undefined;
247+
const shell: import("@cloudflare/computer-rpc").ShellRPC = {
248+
async exec(input) {
249+
receivedEnv = input.env;
250+
const id = input.id ?? "env-command";
251+
return {
252+
id,
253+
events: new ReadableStream({
254+
start(controller) {
255+
controller.enqueue({ id, seq: 1, name: "exit", value: 0 });
256+
controller.close();
257+
},
258+
}),
259+
};
260+
},
261+
getExec: () => Promise.reject(new Error("not used")),
262+
killExec: () => Promise.reject(new Error("not used")),
263+
disposeExec: async () => undefined,
264+
};
265+
const backend: WorkspaceBackend = {
266+
id: "command",
267+
type: "fake",
268+
async connect() {
269+
return { rpc: { sync: fakeRpc(), shell }, sync: "none", close: async () => undefined };
270+
},
271+
};
272+
const ws = new Workspace({ storage: makeStorage(), backends: [backend] });
273+
await drainExec(
274+
await ws.runtime.exec("printenv TOKEN", {
275+
backend: "command",
276+
env: { TOKEN: "secret", EMPTY: "" },
277+
}),
278+
);
279+
expect(receivedEnv).toEqual({ TOKEN: "secret", EMPTY: "" });
280+
});
281+
245282
it("flushes incomplete trailing UTF-8 from command execution", async () => {
246283
const id = "utf8-command";
247284
const shell: import("@cloudflare/computer-rpc").ShellRPC = {
@@ -378,6 +415,27 @@ describe("Workspace backend selection", () => {
378415
expect(replayCalls).toBe(0);
379416
});
380417

418+
it("rejects process environment variables for module backends", async () => {
419+
let connected = false;
420+
const backend: WorkspaceModuleBackend = {
421+
protocol: "module",
422+
id: "module",
423+
type: "test-module",
424+
async connect() {
425+
connected = true;
426+
throw new Error("module backend should not connect");
427+
},
428+
};
429+
const ws = new Workspace({ storage: makeStorage(), backends: [backend] });
430+
await expect(
431+
ws.runtime.exec("export default 42", {
432+
backend: "module",
433+
env: { TOKEN: "secret" },
434+
}),
435+
).rejects.toThrow(/does not accept environment variables/);
436+
expect(connected).toBe(false);
437+
});
438+
381439
it("throws on an unknown backend id", async () => {
382440
const ws = new Workspace({
383441
storage: makeStorage(),

packages/computerd/src/exec/runner.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ test("exec captures stdout and propagates exit code", async () => {
7878
}
7979
});
8080

81+
test("per-execution env overrides the base env without leaking to later commands", async () => {
82+
const { runner, dispose } = fixture({ env: { TOKEN: "base", BASE_ONLY: "yes" } });
83+
try {
84+
const first = runner.exec('printf \'%s|%s|%s\' "$TOKEN" "$BASE_ONLY" "$EMPTY"', {
85+
env: { TOKEN: "override", EMPTY: "" },
86+
});
87+
const firstEvents = await drain(first.events);
88+
const firstStdout = firstEvents
89+
.filter((event) => event.name === "stdout")
90+
.map((event) => decode(event.value as Uint8Array))
91+
.join("");
92+
expect(firstStdout).toBe("override|yes|");
93+
94+
const second = runner.exec("printf '%s' \"$TOKEN\"");
95+
const secondEvents = await drain(second.events);
96+
const secondStdout = secondEvents
97+
.filter((event) => event.name === "stdout")
98+
.map((event) => decode(event.value as Uint8Array))
99+
.join("");
100+
expect(secondStdout).toBe("base");
101+
} finally {
102+
dispose();
103+
}
104+
});
105+
81106
test("reusing a live id throws EEXEC_BUSY", async () => {
82107
const { runner, dispose } = fixture();
83108
try {

0 commit comments

Comments
 (0)