Skip to content

Commit 042893d

Browse files
committed
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.
1 parent 4a9e7a7 commit 042893d

11 files changed

Lines changed: 145 additions & 8 deletions

File tree

docs/05_runtime_interface.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ interface WorkspaceRuntimeExecOptions {
3131
encoding?: "utf8";
3232
input?: WorkspaceRuntimeValue;
3333
timeoutMs?: number;
34+
env?: Record<string, string>;
35+
stdin?: Uint8Array | string;
3436
}
3537

3638
interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEvent> {
@@ -42,7 +44,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEven
4244
}
4345
```
4446

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.
47+
`input` is accepted by callable backends and rejected by the rest; it carries a structured value that the callable backend returns a structured value for. `env` is accepted everywhere: command backends inherit it for the spawned command, and the JavaScript module backend exposes it through `process.env`. Its values apply to that execution only and do not change later executions. `stdin` is the caller-supplied standard input, accepted by backends that model it (the JavaScript module backend reads it through `process.stdin`). `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.
4648

4749
## Results
4850

packages/computer/src/backends/worker-shell/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-shell/entrypoint.ts

Lines changed: 4 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 {
@@ -105,6 +106,7 @@ export class ShellWorker<
105106
command: string,
106107
options: {
107108
cwd?: string;
109+
env?: Record<string, string>;
108110
signal?: AbortSignal;
109111
customCommands: CustomCommand[];
110112
},
@@ -171,6 +173,7 @@ export class ShellWorker<
171173
if (this.bashFactoryOverride !== undefined) {
172174
result = await this.bashFactoryOverride(input.command, {
173175
cwd,
176+
env: input.env,
174177
signal: controller.signal,
175178
customCommands,
176179
});
@@ -192,7 +195,7 @@ export class ShellWorker<
192195
defenseInDepth: { enabled: false },
193196
executionLimits: { maxOutputSize: MAX_OUTPUT_BYTES },
194197
});
195-
result = await bash.exec(input.command, { cwd, signal: controller.signal });
198+
result = await bash.exec(input.command, { cwd, env: input.env, signal: controller.signal });
196199
}
197200
} catch (error) {
198201
const message = error instanceof Error ? error.message : String(error);

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

Lines changed: 37 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
},
@@ -148,6 +160,29 @@ describe("WorkerShellBackend", () => {
148160
expect(envelope.id).toBe("run-1");
149161
});
150162

163+
it("forwards per-execution environment variables to the fetcher", async () => {
164+
let observedEnv: Record<string, string> | undefined;
165+
const fetcher = fakeFetcher((input) => {
166+
observedEnv = input.env;
167+
return {
168+
id: "env",
169+
events: framedStream([{ id: "env", seq: 1, name: "exit", value: 0 }]),
170+
};
171+
});
172+
const backend = new WorkerShellBackend({ fetcher: () => fetcher });
173+
const handle = await backend.connect();
174+
const envelope = await handle.rpc.shell.exec({
175+
command: "printenv TOKEN",
176+
env: { TOKEN: "secret", EMPTY: "" },
177+
});
178+
const reader = envelope.events.getReader();
179+
while (true) {
180+
const { done } = await reader.read();
181+
if (done) break;
182+
}
183+
expect(observedEnv).toEqual({ TOKEN: "secret", EMPTY: "" });
184+
});
185+
151186
it("errors the stream on malformed execution frames", async () => {
152187
const fetcher = fakeFetcher(() => ({
153188
id: "bad",

packages/computer/src/backends/worker-shell/worker-shell.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 WorkerShellBackend 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export class WorkspaceRuntime {
6060
encoding: options.encoding,
6161
id: options.id,
6262
timeoutMs: options.timeoutMs,
63+
env: options.env,
6364
});
6465
return wrapCommandHandle(handle, backend);
6566
}

packages/computer/src/shell.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ 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
109+
// override the backend's base environment without changing later
110+
// executions.
111+
env?: Record<string, string>;
108112
// Backend selector. Omit to use the default backend (the first
109113
// one passed to the Workspace constructor); pass the id of
110114
// another configured backend to route this call there.
@@ -177,6 +181,7 @@ export class WorkspaceShell {
177181
id: options.id,
178182
cwd: options.cwd,
179183
timeoutMs: options.timeoutMs,
184+
env: options.env,
180185
}),
181186
(span, outcome) => {
182187
if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id);

packages/computer/src/workspace.test.ts

Lines changed: 37 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 = {

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 {

packages/rpc/src/interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export interface ShellRPC {
104104
// 0 disables the timeout. Omit to use the runner's default
105105
// (typically 320_000).
106106
timeoutMs?: number;
107+
// Environment variables inherited by this command only.
108+
env?: Record<string, string>;
107109
}): Promise<{
108110
id: string;
109111
events: ReadableStream<ExecEvent>;

0 commit comments

Comments
 (0)