Skip to content
Merged
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.
95 changes: 70 additions & 25 deletions docs/12_worker_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";

The container backend (`@cloudflare/computer/backends/container`)
gives you a real Linux environment with arbitrary binaries on
`$PATH`, network, and a full POSIX filesystem. It costs a container
per session and a real roundtrip on every filesystem op.
`$PATH`, optional network access, and a full POSIX filesystem. It costs a
container per session and a real roundtrip on every filesystem op.

The worker backend trades the real environment for a Workers
isolate that boots instantly, scales out cheaply, and has no
Expand Down Expand Up @@ -74,15 +74,34 @@ host DO ─── Workspace ─── WorkerShellBackend
back to host DO's SQLite
```

The Worker Loader caches the Dynamic Worker isolate by id. The
default id is `workspace-shell:${workspace.id}` — one isolate per
workspace, so concurrent execs in the same workspace share a warm
isolate, and a runaway Bash run in one workspace can't touch
another workspace's shell.
The Worker Loader caches the Dynamic Worker isolate by id. The backend
starts with `workspace-shell:${workspace.id}` and adds the egress policy
identity. Concurrent execs in the same workspace and policy can share a
warm isolate, while a policy change cannot reuse an isolate with broader
network authority.

`globalOutbound: null` on the Dynamic Worker blocks `fetch()` and
`connect()` from inside the shell. The only path out of the
isolate is back through the host DO over `env.HOST`.
Ambient network access is blocked by default. All execution backends use
the same `WorkspaceEgressPolicy` modes:

```ts
new WorkerShellBackend({
loader: env.LOADER,
workspace: { binding: "ContainerExample", id: ctx.id.toString() },
ctx,
egress: { mode: "none" },
});
```

Use `{ mode: "direct" }` to let the Dynamic Worker use its native outbound
network, or pass `{ mode: "http-gateway", gateway, revision }` to route HTTP
and HTTPS requests through a `Fetcher`. A stable `revision` lets the Loader
reuse an isolate until the gateway policy changes. Without one, the backend
uses a fresh cache identity for each backend lifetime.

These modes govern ambient `fetch()` and `connect()` calls from the shell.
Host-side capabilities remain separate; for example, the host-forwarded Git
command can have its own network authority while ambient shell networking is
blocked.

## Built-in custom commands

Expand Down Expand Up @@ -156,16 +175,42 @@ and re-encodes string payloads (`stdout` / `stderr`) into
accumulate the result from raw events, see the shape they already
handle.

## Fetcher factory escape hatch
## Runtime sources

`WorkerShellBackend` is source-agnostic. The common case takes
`{ loader, workspace, ctx }` and builds the loader callback
itself. For deployments that need a different Fetcher source — a
Workers service binding, a Workers-for-Platforms dispatch
namespace, a fake in tests — pass `fetcher: () => unknown |
Promise<unknown>` instead. The factory is consulted once on
`connect()`; the resolved Fetcher is held for the life of the
handle.
The managed path takes `{ loader, workspace, ctx }` and builds the Loader
callback. The equivalent explicit source is:

```ts
new WorkerShellBackend({
source: {
type: "loader",
loader: env.LOADER,
workspace: { binding: "ContainerExample", id: ctx.id.toString() },
ctx,
},
egress: { mode: "none" },
});
```

Deployments that obtain a shell from a service binding, Workers for Platforms
dispatch namespace, broker, pool, or test fake can use an external runtime
source:

```ts
new WorkerShellBackend({
source: {
type: "external-runtime",
async connect({ egress }) {
return createShellRuntime({ egress });
},
},
egress: { mode: "direct" },
});
```

The source is consulted once per backend `connect()`. It receives the policy
before it creates or selects a runtime and is responsible for enforcing that
policy. The backend does not own the external runtime's lifecycle.

## Known fidelity gaps

Expand Down Expand Up @@ -297,10 +342,10 @@ default-on cost to opt out of. The full set of optional groups is

`curl` runs on a `SecureFetch` adapter over the isolate's global
`fetch` — `undici` is redirected to a throwing stub at build time
and never ships. Egress stays governed by the Dynamic Worker's
`globalOutbound` (left `null`, i.e. closed), not by the shell, so
enabling `curl` does not by itself open the network.
and never ships. Egress stays governed by the backend's
`WorkspaceEgressPolicy`, not by the shell, so enabling `curl` does not by
itself open the network.

Consumers that build the Loader callback by hand (the `fetcher`
path) assemble the modules table themselves with
`assembleShellModules([...groups])` from the same package.
An external runtime source that builds its own Loader callback can assemble
the modules table with `assembleShellModules([...groups])` from the same
package.
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
5 changes: 5 additions & 0 deletions examples/tutorial/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,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 All @@ -170,6 +171,10 @@ export class RecipeAgent extends withWorkspaceContainer(RecipeBase) {
}
```

The explicit `direct` policy preserves the example's outbound Internet
access. Use `{ mode: "none" }` when commands in the container do not need
network access.

`withWorkspaceContainer` mixes the container lifecycle into Think, so
the durable object can start and stop its own container. The
`workspace: { binding, id }` pair is how the container finds its way
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" },
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
});

override workspace = new Workspace({
Expand Down
129 changes: 125 additions & 4 deletions packages/computer/src/backends/container/cloudflare-container.test.ts
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,118 @@ 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("restores tokenized egress callbacks before calling the gateway", async () => {
const fake = makeFakeHost({ healthy: false });
let gatewayRequest: Request | undefined;
const gateway = {
fetch: vi.fn(async (request: Request) => {
gatewayRequest = request;
return 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://workspace.internal/ws", {
method: "POST",
body: "payload",
headers: {
"x-workspace-egress-token": fake.gatewayToken ?? "",
"x-workspace-egress-url": "https://api.example.test/data?format=json",
},
});

const response = await backend.handleFetch(request);

expect(fake.gatewayWorkspace).toEqual(fakeWorkspace);
expect(await response.text()).toBe("https://api.example.test/data?format=json");
expect(gatewayRequest?.method).toBe("POST");
expect(await gatewayRequest?.text()).toBe("payload");
expect(gatewayRequest?.headers.get("x-workspace-egress-token")).toBeNull();
expect(gatewayRequest?.headers.get("x-workspace-egress-url")).toBeNull();
expect(gateway.fetch).toHaveBeenCalledOnce();
});

test("rejects tokenized egress callbacks without a valid original URL", 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://workspace.internal/ws", {
headers: {
"x-workspace-egress-token": fake.gatewayToken ?? "",
"x-workspace-egress-url": "ftp://api.example.test/data",
},
}),
);

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

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
Loading
Loading