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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public surface. Each is a Worker workspace with its own README.
- [`examples/worker-javascript`](examples/worker-javascript) — mirrors
`worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic
Worker instead of running a shell command.
- [`examples/egress`](examples/egress) — sends one URL through the container,
Worker shell, and Worker JavaScript backends with matching `none`, `all`, or
custom egress policies.
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
chat agent that uses the workspace as its working directory, reachable
from a terminal.
Expand Down
5 changes: 5 additions & 0 deletions examples/egress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
.wrangler/
.dev.vars*
.env*
17 changes: 17 additions & 0 deletions examples/egress/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd

FROM debian:bookworm-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fuse3 libfuse2 ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*

COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd

ENV PORT=8080
ENV MOUNT_POINT=/workspace
ENV FUSE_MOUNT=auto
EXPOSE 8080

ENTRYPOINT ["/usr/local/bin/computerd"]
73 changes: 73 additions & 0 deletions examples/egress/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# egress example

> [!IMPORTANT]
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
> APIs are unstable and the design is subject to change.

This Worker sends one URL through all three Cloudflare Computer execution backends:

- the container shell runs `curl`;
- the Worker shell runs its `curl` command;
- Worker JavaScript runs `fetch`.

`EGRESS_MODE` applies the same egress policy to every backend.

| `EGRESS_MODE` | Workspace policy | Behavior |
| --- | --- | --- |
| `none` | `{ mode: "none" }` | Blocks outbound network access. This is the default. |
| `all` | `{ mode: "direct" }` | Allows direct outbound access. |
| `custom` | `{ mode: "http-gateway" }` | Routes requests through a gateway that allows only `https://example.com`. Other origins receive `403`. |

## Run it

The container backend requires Docker. Start the example with one of the three modes:

```sh
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:none
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:all
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:custom
```

Then send a request:

```sh
curl -X POST 'http://127.0.0.1:8787/fetch?url=https%3A%2F%2Fexample.com'
```

The endpoint returns the status code and MIME type from each backend, or an error when no response was available:

```json
{
"mode": "all",
"url": "https://example.com/",
"container": { "status": 200, "mimeType": "text/html" },
"worker-shell": { "status": 200, "mimeType": "text/html" },
"worker-javascript": { "status": 200, "mimeType": "text/html" }
}
```

With `EGRESS_MODE=none`, each backend returns an `error` object. With `EGRESS_MODE=custom`, try an origin outside the allowlist to see the gateway response:

```sh
curl -X POST 'http://127.0.0.1:8787/fetch?url=https%3A%2F%2Fcloudflare.com'
```

Each backend reports status `403` and MIME type `text/plain`.

## HTTP surface

```text
POST /fetch?url=<HTTP-or-HTTPS-URL>
```

The endpoint accepts only `POST`. Missing, malformed, and non-HTTP URLs return `400`.

## Layout

```text
examples/egress/
Dockerfile computerd, FUSE libraries, and curl
wrangler.jsonc Worker Loader, container, durable object, and EGRESS_MODE
src/egress.ts egress policy and response helpers
src/index.ts gateway, three backends, and HTTP handler
```
21 changes: 21 additions & 0 deletions examples/egress/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@example/computer-egress",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Example Worker that compares egress policies across the Cloudflare Computer execution backends.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cloudflare/computer": "*"
},
"devDependencies": {
"typescript": "^6.0.3",
"vitest": "^4.1.7",
"wrangler": "^4.107.1"
}
}
84 changes: 84 additions & 0 deletions examples/egress/src/egress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";

import { mimeType, parseShellFetchResult, targetUrl, workspaceEgressPolicy } from "./egress.js";

describe("workspaceEgressPolicy", () => {
it("blocks egress in none mode", () => {
const gateway = vi.fn<() => Fetcher>();

expect(workspaceEgressPolicy("none", gateway)).toEqual({ mode: "none" });
expect(gateway).not.toHaveBeenCalled();
});

it("uses direct egress in all mode", () => {
const gateway = vi.fn<() => Fetcher>();

expect(workspaceEgressPolicy("all", gateway)).toEqual({ mode: "direct" });
expect(gateway).not.toHaveBeenCalled();
});

it("uses the custom HTTP gateway in custom mode", () => {
const fetcher = { fetch: vi.fn() } as unknown as Fetcher;
const gateway = vi.fn(() => fetcher);

expect(workspaceEgressPolicy("custom", gateway)).toEqual({
mode: "http-gateway",
gateway: fetcher,
revision: "v1",
});
expect(gateway).toHaveBeenCalledOnce();
});

it("rejects unsupported modes", () => {
expect(() => workspaceEgressPolicy("invalid", vi.fn())).toThrow(
'EGRESS_MODE must be "none", "all", or "custom"; got "invalid"',
);
});
});

describe("mimeType", () => {
it("removes content type parameters", () => {
expect(mimeType("text/html; charset=UTF-8")).toBe("text/html");
});

it("returns null for a missing content type", () => {
expect(mimeType(null)).toBeNull();
expect(mimeType("")).toBeNull();
});
});

describe("targetUrl", () => {
it("accepts HTTP and HTTPS URLs", () => {
expect(targetUrl("https://example.com/path")).toBe("https://example.com/path");
expect(targetUrl("http://example.com")).toBe("http://example.com/");
});

it("rejects missing, malformed, and non-HTTP URLs", () => {
expect(() => targetUrl(null)).toThrow("url query parameter is required");
expect(() => targetUrl("not a URL")).toThrow("url must be a valid HTTP or HTTPS URL");
expect(() => targetUrl("ftp://example.com/file")).toThrow(
"url must be a valid HTTP or HTTPS URL",
);
});
});

describe("parseShellFetchResult", () => {
it("returns the status and MIME type from curl output", () => {
expect(parseShellFetchResult(0, "204\ntext/plain; charset=utf-8", "")).toEqual({
status: 204,
mimeType: "text/plain",
});
});

it("returns an error when curl fails", () => {
expect(parseShellFetchResult(7, "", "curl: network access denied\n")).toEqual({
error: "curl: network access denied",
});
});

it("returns an error for malformed curl output", () => {
expect(parseShellFetchResult(0, "not-a-status\ntext/plain", "")).toEqual({
error: "curl returned an invalid status code",
});
});
});
60 changes: 60 additions & 0 deletions examples/egress/src/egress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { WorkspaceEgressPolicy } from "@cloudflare/computer";

export type EgressMode = "none" | "all" | "custom";

export type FetchResult = { status: number; mimeType: string | null } | { error: string };

export function workspaceEgressPolicy(mode: string, gateway: () => Fetcher): WorkspaceEgressPolicy {
switch (mode) {
case "none":
return { mode: "none" };
case "all":
return { mode: "direct" };
case "custom":
return { mode: "http-gateway", gateway: gateway(), revision: "v1" };
default:
throw new Error(`EGRESS_MODE must be "none", "all", or "custom"; got "${mode}"`);
}
}

export function mimeType(contentType: string | null): string | null {
return contentType?.split(";", 1)[0]?.trim() || null;
}

export function targetUrl(value: string | null): string {
if (value === null || value.length === 0) {
throw new Error("url query parameter is required");
}

let url: URL;
try {
url = new URL(value);
} catch {
throw new Error("url must be a valid HTTP or HTTPS URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("url must be a valid HTTP or HTTPS URL");
}
return url.href;
}

export function parseShellFetchResult(
exitCode: number,
stdout: string,
stderr: string,
): FetchResult {
if (exitCode !== 0) {
return { error: stderr.trim() || `curl exited with code ${exitCode}` };
}

const [statusLine, contentType = ""] = stdout.trimEnd().split("\n");
const status = Number(statusLine);
if (!Number.isInteger(status) || status < 100 || status > 599) {
return { error: "curl returned an invalid status code" };
}
return { status, mimeType: mimeType(contentType) };
}

export function errorResult(error: unknown): FetchResult {
return { error: error instanceof Error ? error.message : String(error) };
}
Loading
Loading