Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/calm-egress-policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Configure ambient network access consistently across execution backends.
1 change: 1 addition & 0 deletions examples/container/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class ContainerBase extends withWorkspaceContainer(class extends DurableObject<E
readonly backend = new CloudflareContainerBackend({
container: () => this,
workspace: { binding: "ContainerExample", id: this.ctx.id.toString() },
egress: { mode: "direct" },
});
}

Expand Down
1 change: 1 addition & 0 deletions examples/think-compare-runtimes/worker/think/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export class WorkspaceThinkAgent extends RuntimeThinkAgent {
);
},
workspace: workspaceRef,
egress: { mode: "direct" },
containerEnv: this.env.FUSE_MOUNT ? { FUSE_MOUNT: this.env.FUSE_MOUNT } : undefined,
});
const workspace = new Workspace({
Expand Down
1 change: 1 addition & 0 deletions examples/think/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export class Assistant extends withWorkspaceContainer(AssistantBase) {
id: "container",
container: () => this,
workspace: workspaceRef(this.ctx),
egress: { mode: "direct" },
});

/**
Expand Down
1 change: 1 addition & 0 deletions examples/tutorial/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class RecipeAgent extends withWorkspaceContainer(RecipeBase) {
readonly #backend = new CloudflareContainerBackend({
container: () => this,
workspace: { binding: "RecipeAgent", id: this.ctx.id.toString() },
egress: { mode: "direct" },
});

override workspace = new Workspace({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ interface FakeHost {
host: IWorkspaceContainerAPI;
calls: { name: string; args: unknown[] }[];
startEnv?: Record<string, string>;
enableInternet?: boolean;
interceptedHost?: string;
interceptedWorkspace?: WorkspaceRef;
gatewayWorkspace?: WorkspaceRef;
gatewayToken?: string;
running: boolean;
exit: { exitedAt: number; reason: string } | null;
simulateExit(reason: string): void;
Expand Down Expand Up @@ -62,9 +65,10 @@ function makeFakeHost(opts: FakeHostOptions = {}): FakeHost {
}

state.host = {
async start(env) {
calls.push({ name: "start", args: [env] });
async start(env, enableInternet) {
calls.push({ name: "start", args: [env, enableInternet] });
state.startEnv = env;
state.enableInternet = enableInternet;
state.running = true;
// A successful start clears any prior exit, matching
// WorkspaceContainerAPI.start.
Expand All @@ -75,6 +79,11 @@ function makeFakeHost(opts: FakeHostOptions = {}): FakeHost {
state.interceptedHost = host;
state.interceptedWorkspace = ref;
},
async interceptAllOutboundHttp(ref, token) {
calls.push({ name: "interceptAllOutboundHttp", args: [ref, token] });
state.gatewayWorkspace = ref;
state.gatewayToken = token;
},
async fetchPort(port, input, init) {
const request = input instanceof Request ? input : new Request(input, init);
const url = new URL(request.url);
Expand All @@ -94,8 +103,8 @@ function makeFakeHost(opts: FakeHostOptions = {}): FakeHost {
port() {
throw new Error("cross-boundary Fetchers should not be used by CloudflareContainerBackend");
},
async restart(env) {
calls.push({ name: "restart", args: [env] });
async restart(env, enableInternet) {
calls.push({ name: "restart", args: [env, enableInternet] });
if (opts.restart) {
await opts.restart();
}
Expand Down Expand Up @@ -135,6 +144,79 @@ describe("CloudflareContainerBackend", () => {
expect(fake.interceptedWorkspace).toEqual(fakeWorkspace);
});

test("blocks ambient egress by default", async () => {
const fake = makeFakeHost({ healthy: false });
const backend = new CloudflareContainerBackend({
container: () => ({ getWorkspaceContainer: () => fake.host }),
workspace: fakeWorkspace,
connectTimeoutMs: 300,
});

await expect(backend.connect()).rejects.toThrow();

expect(fake.enableInternet).toBe(false);
});

test("enables direct ambient egress", async () => {
const fake = makeFakeHost({ healthy: false });
const backend = new CloudflareContainerBackend({
container: () => ({ getWorkspaceContainer: () => fake.host }),
workspace: fakeWorkspace,
connectTimeoutMs: 300,
egress: { mode: "direct" },
});

await expect(backend.connect()).rejects.toThrow();

expect(fake.enableInternet).toBe(true);
});

test("routes HTTP egress through the configured gateway", async () => {
const fake = makeFakeHost({ healthy: false });
const gateway = {
fetch: vi.fn(async (request: Request) => new Response(request.url)),
} as unknown as Fetcher;
const backend = new CloudflareContainerBackend({
container: () => ({ getWorkspaceContainer: () => fake.host }),
workspace: fakeWorkspace,
connectTimeoutMs: 300,
egress: { mode: "http-gateway", gateway },
});
await expect(backend.connect()).rejects.toThrow();
const request = new Request("https://api.example.test/data", {
headers: { "x-workspace-egress-token": fake.gatewayToken ?? "" },
});

const response = await backend.handleFetch(request);

expect(fake.gatewayWorkspace).toEqual(fakeWorkspace);
expect(await response.text()).toBe("https://api.example.test/data");
expect(gateway.fetch).toHaveBeenCalledOnce();
});

test("rejects container egress callbacks with the wrong token", async () => {
const fake = makeFakeHost({ healthy: false });
const gateway = {
fetch: vi.fn(async () => new Response("forwarded")),
} as unknown as Fetcher;
const backend = new CloudflareContainerBackend({
container: () => ({ getWorkspaceContainer: () => fake.host }),
workspace: fakeWorkspace,
connectTimeoutMs: 300,
egress: { mode: "http-gateway", gateway },
});
await expect(backend.connect()).rejects.toThrow();

const response = await backend.handleFetch(
new Request("https://api.example.test/data", {
headers: { "x-workspace-egress-token": "wrong" },
}),
);

expect(response.status).toBe(404);
expect(gateway.fetch).not.toHaveBeenCalled();
});

test("egressHost option overrides the default", async () => {
const fake = makeFakeHost({ healthy: false });
const backend = new CloudflareContainerBackend({
Expand Down
28 changes: 25 additions & 3 deletions packages/computer/src/backends/container/cloudflare-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { newWebSocketRpcSession, type RpcStub } from "capnweb";

import type { BackendHandle, WorkspaceBackend } from "../../backend.js";
import { startHeartbeat } from "../../heartbeat.js";
import { WORKSPACE_EGRESS_TOKEN_HEADER, type WorkspaceEgressPolicy } from "../../runtime/egress.js";
import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js";
import { probeComputerdHealth } from "./health-probe.js";

Expand Down Expand Up @@ -83,6 +84,8 @@ export interface CloudflareContainerBackendOptions {
// sharing the same container host.
egressHost?: string;

egress?: WorkspaceEgressPolicy;

// TCP port computerd listens on inside the container. Default 8080,
// matching the Dockerfile shipped with examples/container.
containerPort?: number;
Expand Down Expand Up @@ -143,9 +146,14 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
readonly id: string;

readonly #options: Required<
Omit<CloudflareContainerBackendOptions, "container" | "workspace" | "containerEnv" | "id">
Omit<
CloudflareContainerBackendOptions,
"container" | "workspace" | "containerEnv" | "egress" | "id"
>
> &
Pick<CloudflareContainerBackendOptions, "container" | "workspace" | "containerEnv">;
readonly #egress: WorkspaceEgressPolicy;
readonly #egressToken: string | undefined;

// State for the in-flight /ws upgrade. handleFetch() resolves
// #pendingUpgrade; connect() awaits it.
Expand All @@ -159,6 +167,8 @@ export class CloudflareContainerBackend implements WorkspaceBackend {

constructor(options: CloudflareContainerBackendOptions) {
this.id = options.id ?? "container-shell";
this.#egress = options.egress ?? { mode: "none" };
this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined;
this.#options = {
container: options.container,
workspace: options.workspace,
Expand Down Expand Up @@ -195,8 +205,11 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
MOUNT_POINT: "/workspace",
...this.#options.containerEnv,
};
await host.start(env);
await host.start(env, this.#egress.mode === "direct");
await host.interceptOutboundHttp(this.#options.egressHost, this.#options.workspace);
if (this.#egress.mode === "http-gateway" && this.#egressToken !== undefined) {
await host.interceptAllOutboundHttp(this.#options.workspace, this.#egressToken);
}

// Arm the upgrade promise before posting /connect — computerd
// dials back as soon as /health on the egress answers, so
Expand Down Expand Up @@ -286,6 +299,15 @@ export class CloudflareContainerBackend implements WorkspaceBackend {
// Returns the 101 response that the WorkspaceProxy fetch handler
// forwards back to the container.
async handleFetch(req: Request): Promise<Response> {
if (
this.#egress.mode === "http-gateway" &&
this.#egressToken !== undefined &&
req.headers.get(WORKSPACE_EGRESS_TOKEN_HEADER) === this.#egressToken
) {
const headers = new Headers(req.headers);
headers.delete(WORKSPACE_EGRESS_TOKEN_HEADER);
return this.#egress.gateway.fetch(new Request(req, { headers }));
}
const url = new URL(req.url);
if (url.pathname !== "/ws") {
return new Response("not found", { status: 404 });
Expand Down Expand Up @@ -365,7 +387,7 @@ export class CloudflareContainerBackend implements WorkspaceBackend {

if (attempt < maxAttempts) {
try {
await host.restart(env);
await host.restart(env, this.#egress.mode === "direct");
restarts++;
} catch (error) {
this.#rejectUpgrade?.(error);
Expand Down
26 changes: 19 additions & 7 deletions packages/computer/src/backends/container/container-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ export interface IWorkspaceContainerAPI {
// Idempotent start. Returns once the runtime has accepted the
// start command; readiness is verified by the backend through
// probeComputerdHealth against port().
start(env: Record<string, string>): Promise<void>;
start(env: Record<string, string>, enableInternet: boolean): Promise<void>;

// Wire `host` → workspace inside the container's egress table.
// Called once per backend connect(). The implementation
// constructs the loopback Fetcher locally from {binding, id},
// because Fetchers can't survive a Workers RPC hop.
interceptOutboundHttp(host: string, workspace: WorkspaceRef): Promise<void>;
interceptAllOutboundHttp(workspace: WorkspaceRef, token: string): Promise<void>;

// Fetch against a named TCP port inside the container. The fetch
// runs in the container-owning Durable Object, so callers across
Expand All @@ -68,7 +69,7 @@ export interface IWorkspaceContainerAPI {
// current generation dead. Implementation: destroy() the
// container, then start({ env }). Callers bound the number of
// restart attempts — this method does no looping of its own.
restart(env: Record<string, string>): Promise<void>;
restart(env: Record<string, string>, enableInternet: boolean): Promise<void>;

// Coarse diagnostic state. The `running` flag reports whether
// the platform still has a container instance attached; it does
Expand Down Expand Up @@ -104,7 +105,7 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
this.#ctx = ctx;
}

async start(env: Record<string, string>) {
async start(env: Record<string, string>, enableInternet: boolean) {
// If a prior generation has died, commit to a fresh one: the
// destroy clears any platform-side carcass, and the start that
// follows is unconditional. We cannot rely on
Expand All @@ -119,14 +120,14 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
// best-effort — the next start() will surface any real
// platform-side failure.
}
this.#container.start({ enableInternet: true, env });
this.#container.start({ enableInternet, env });
} else if (!this.#container.running) {
this.#container.start({ enableInternet: true, env });
this.#container.start({ enableInternet, env });
}
installContainerMonitor(this.#ctx, this.#container);
}

async restart(env: Record<string, string>) {
async restart(env: Record<string, string>, enableInternet: boolean) {
// destroy() resolves once the platform has torn down the
// attached container. A subsequent start() launches a fresh
// generation — ports re-bind, the computerd daemon comes up clean.
Expand All @@ -139,7 +140,7 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
// succeed against a fresh generation or surface its own
// failure.
}
this.#container.start({ enableInternet: true, env });
this.#container.start({ enableInternet, env });
installContainerMonitor(this.#ctx, this.#container);
}

Expand All @@ -151,6 +152,17 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
return containerExitInfo(this.#ctx);
}

async interceptAllOutboundHttp(ref: WorkspaceRef, token: string) {
const exports = (this.#ctx as unknown as { exports: Record<string, unknown> }).exports as {
WorkspaceProxy: (opts: { props: WorkspaceRef & { egressToken: string } }) => Fetcher;
};
const proxy = exports.WorkspaceProxy({ props: { ...ref, egressToken: token } });
await Promise.all([
this.#container.interceptAllOutboundHttp(proxy),
this.#container.interceptOutboundHttps("*", proxy),
]);
}

async interceptOutboundHttp(host: string, ref: WorkspaceRef) {
// ctx.exports.WorkspaceProxy is bound by name in the
// consumer's Worker (they re-export WorkspaceProxy from this
Expand Down
1 change: 1 addition & 0 deletions packages/computer/src/backends/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// withWorkspaceContainer,
// } from "@cloudflare/computer/backends/container";

export type { WorkspaceEgressPolicy } from "../../runtime/egress.js";
export {
CloudflareContainerBackend,
type CloudflareContainerBackendOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/computer/src/backends/worker-javascript/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { WorkspaceEgressPolicy } from "../../runtime/egress.js";
export {
WorkerJavaScriptBackend,
type WorkerJavaScriptBackendOptions,
Expand Down
Loading
Loading