Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/05_runtime_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface WorkspaceRuntimeExecOptions {
encoding?: "utf8";
input?: WorkspaceRuntimeValue;
timeoutMs?: number;
env?: Record<string, string>;
}

interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEvent> {
Expand All @@ -42,7 +43,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEven
}
```

`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.
`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should support piping env into module backends too, in a worker any env variable is populated on process.env.


## Results

Expand Down
15 changes: 14 additions & 1 deletion packages/computer/src/backends/worker/entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class TestShellWorker extends ShellWorker {
env: E,
bashFactory: (
command: string,
options: { cwd?: string; signal?: AbortSignal },
options: { cwd?: string; env?: Record<string, string>; signal?: AbortSignal },
) => Promise<{ stdout: string; stderr: string; exitCode: number }>,
): TestShellWorker {
const w = new TestShellWorker(undefined as never, env as never);
Expand Down Expand Up @@ -189,6 +189,19 @@ describe("ShellWorker", () => {
expect(observedCwd).toBe("/workspace/src");
});

it("forwards per-execution environment variables to Bash", async () => {
let observedEnv: Record<string, string> | 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" });
Expand Down
9 changes: 8 additions & 1 deletion packages/computer/src/backends/worker/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ExecInput {
cwd?: string;
id?: string;
timeoutMs?: number;
env?: Record<string, string>;
}

export interface ShellWorkerOptions {
Expand Down Expand Up @@ -105,6 +106,7 @@ export class ShellWorker<
command: string,
options: {
cwd?: string;
env?: Record<string, string>;
signal?: AbortSignal;
customCommands: CustomCommand[];
},
Expand Down Expand Up @@ -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,
});
Expand All @@ -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);
Expand Down
36 changes: 34 additions & 2 deletions packages/computer/src/backends/worker/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}): Promise<{
id: string;
events: ReadableStream<Uint8Array>;
}>;
Expand All @@ -58,7 +64,13 @@ function framedStream(events: WireEvent[]): ReadableStream<Uint8Array> {
}

function fakeFetcher(
exec: (input: { command: string; cwd?: string; id?: string; timeoutMs?: number }) => {
exec: (input: {
command: string;
cwd?: string;
id?: string;
timeoutMs?: number;
env?: Record<string, string>;
}) => {
id: string;
events: ReadableStream<Uint8Array>;
},
Expand Down Expand Up @@ -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<string, string> | 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
Expand Down
9 changes: 8 additions & 1 deletion packages/computer/src/backends/worker/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}): Promise<{
id: string;
events: ReadableStream<Uint8Array>;
}>;
Expand Down Expand Up @@ -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) };
},
Expand Down
4 changes: 4 additions & 0 deletions packages/computer/src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/computer/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export interface WorkspaceRuntimeExecOptions<E extends ExecEncoding = undefined>
encoding?: E;
input?: WorkspaceRuntimeValue;
timeoutMs?: number;
/** Environment variables for command backends. Module backends reject this option. */
env?: Record<string, string>;
}

export interface WorkspaceRuntimeGetOptions<E extends ExecEncoding = undefined> {
Expand Down
4 changes: 4 additions & 0 deletions packages/computer/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export interface ExecOptions<E extends ExecEncoding = undefined> {
// 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<string, 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.
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions packages/computer/src/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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 = {
Expand Down Expand Up @@ -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(),
Expand Down
25 changes: 25 additions & 0 deletions packages/computerd/src/exec/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/rpc/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}): Promise<{
id: string;
events: ReadableStream<ExecEvent>;
Expand Down
11 changes: 9 additions & 2 deletions packages/rpc/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> },
): {
id: string;
events: ReadableStream<ExecEvent>;
Expand Down Expand Up @@ -230,14 +230,21 @@ 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<string, string>;
}): Promise<{
id: string;
events: ReadableStream<ExecEvent>;
}> {
return this.runner.exec(input.command, {
id: input.id,
cwd: input.cwd,
timeoutMs: input.timeoutMs,
env: input.env,
});
}

Expand Down
8 changes: 7 additions & 1 deletion packages/rpc/tests/shell-and-composite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ExecRecord {
id: string;
command: string;
cwd?: string;
env?: Record<string, string>;
events: ExecEvent[];
killed?: { signal: string };
disposed?: boolean;
Expand Down Expand Up @@ -68,6 +69,7 @@ function makeFakeRunner(): FakeRunner {
id,
command,
cwd: options?.cwd,
env: options?.env,
events: [
{
id,
Expand Down Expand Up @@ -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");
Expand All @@ -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();
}
Expand Down
Loading