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/authorize-container-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

The container's HTTP surface now requires a bearer token. The host generates a secret, passes it to the container as RPC_CLIENT_SECRET at launch, and sends it on /connect. Readiness at /health stays open, and leaving the variable unset disables the checks. Before opening a session the host checks that the container refuses an unauthenticated request and fails the connect if it does not, so a container or image predating this has to be recycled. The container's dial-back to the host carries the same secret, and the host refuses an upgrade that does not present it.
5 changes: 5 additions & 0 deletions .changeset/container-launch-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

`IWorkspaceContainerAPI.start()` and `restart()` now take a single `ContainerLaunchSpec` of `{ env, enableInternet }` instead of two arguments, and return which of `launched`, `adopted` or `relaunched` happened. Each launch records its spec, and a container found already running is relaunched unless it matches, because neither the environment nor the internet flag can be changed on a live container. A container started outside this API has no record and is relaunched rather than trusted. `setInactivityTimeout()` joins the interface so a caller that pre-starts containers, such as a warm pool, does not need to reach past it.
5 changes: 5 additions & 0 deletions .changeset/exec-env-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

A command run through the shell no longer inherits the container's whole environment. It receives PATH, HOME, TMPDIR, TZ, LANG, TERM, the LC_ family, and any variable prefixed COMPUTER_VAR_, which arrives with the prefix stripped so COMPUTER_VAR_NODE_ENV becomes NODE_ENV. A workspace that relied on some other inherited variable needs the prefix.
25 changes: 17 additions & 8 deletions docs/07_injected_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ backend pins it to `8080`) and serves:

| Route | Method | Purpose |
| --- | --- | --- |
| `/health` | `GET`, `HEAD` | Liveness probe; `200 ok\n` as soon as the HTTP server binds. |
| `/health` | `GET`, `HEAD` | Liveness probe; `200 ok\n` as soon as the HTTP server binds. The only route reachable without the shared secret. |
| `/__computerd/info` | `GET` | Runtime info: FUSE backend, mount point, port. |
| `/api` | `GET` (upgrade) | WebSocket capnweb transport — the bootstrap stub is `WorkspaceRPC`. Only the exact path upgrades. A request without an `Upgrade` header gets `400`; an unsupported `Sec-WebSocket-Version` gets `426` and the versions the server speaks. |
| `/api/watermarks` | `GET`, `HEAD` | Sync revisions: `currentRev`, `pushRev`, `fetchCursor`. The same values `sync.watermarks()` returns, for callers that want a few numbers without holding a session. |
Expand Down Expand Up @@ -121,13 +121,20 @@ Provider-agnostic shape — three steps, in order:
`CloudflareContainerBackend` (`packages/computer/src/backends/container/cloudflare-container.ts`)
wires it like this:

1. **Start.** `container.start({ enableInternet, env })` on the
Cloudflare Containers API — not the `@cloudflare/sandbox` SDK.
Idempotence comes from `container.running` plus a cached `#handle`;
there is no process-name registry, no `startProcess`/`getProcess`,
and no `node /app/...` command (the container's `ENTRYPOINT` runs
`computerd` directly). `containerEnv` pins `PORT=8080` and lets the
image's own `FUSE_MOUNT` value (typically `auto`) win.
1. **Start.** `WorkspaceContainerAPI.start({ env, enableInternet })`,
which reaches the Cloudflare Containers API — not the
`@cloudflare/sandbox` SDK. There is no process-name registry, no
`startProcess`/`getProcess`, and no `node /app/...` command (the
container's `ENTRYPOINT` runs `computerd` directly). `containerEnv`
pins `PORT=8080` and lets the image's own `FUSE_MOUNT` value
(typically `auto`) win, and the API adds `RPC_CLIENT_SECRET`.

Neither the environment nor the internet flag can be changed on a
running container, so the launch records both and a container found
already running is only adopted when it matches. Otherwise it is
relaunched, which is what keeps a warm pool from handing a workspace
a container configured for something else. A container started
outside this API has no record and is relaunched too.
2. **Wire egress.** `container.interceptOutboundHttp(egressHost, egress)`
routes outbound HTTP from the container at `egressHost` back to a
Worker `Fetcher` the DO controls.
Expand Down Expand Up @@ -163,6 +170,8 @@ These are the variables `computerd` actually consumes (see
| `MOUNT_POINT` | `/workspace` | Absolute path inside the container to mount the FUSE filesystem at. Ignored when `FUSE_MOUNT=none`. |
| `FUSE_MOUNT` | `auto` | Backend selector: `auto` probes `/dev/fuse` (linux) or macFUSE (darwin) and falls back to the userspace shim; `fuse` / `macfuse` require the corresponding real backend; `shim` forces the userspace shim; `none` skips the mount entirely. |
| `EXEC_LOG_MAX_BYTES` | runner default | Caps the per-exec stdout/stderr log retained in-memory. |
| `RPC_CLIENT_SECRET` | unset | When set, every route except `/health` requires it as `Authorization: Bearer <secret>`, including the `/api` upgrade. Unset leaves the surface open. The Cloudflare backend generates one per workspace and sets it at launch. |
| `COMPUTER_VAR_*` | unset | Forwarded into every `shell.exec` command with the prefix stripped, so `COMPUTER_VAR_NODE_ENV` arrives as `NODE_ENV`. |
| `LOG_FILE` | unset | If set, every `console.log` / `console.error` line and any `uncaughtException` / `unhandledRejection` is also appended to this file. Stdout/stderr behaviour is unchanged. |

When `LOG_FILE` is set, `computerd` mirrors console output into the file in
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ContainerLaunchSpec } from "@cloudflare/computer/backends/container";
import { describe, expect, test, vi } from "vitest";
import type { WorkspaceContainerHost } from "./computer-container-pool";

Expand All @@ -11,8 +12,11 @@ describe("createWorkspaceWarmPoolRuntime", () => {
const { createWorkspaceWarmPoolRuntime } = await import("./computer-container-pool");
const calls: string[] = [];
const host = {
async startWarmContainer(env: Record<string, string>, inactivityTimeoutMs: number) {
calls.push(`start ${env.PORT} ${env.MOUNT_POINT} ${env.FUSE_MOUNT} ${inactivityTimeoutMs}`);
async startWarmContainer(spec: ContainerLaunchSpec, inactivityTimeoutMs: number) {
const { env, enableInternet } = spec;
calls.push(
`start ${env.PORT} ${env.MOUNT_POINT} ${env.FUSE_MOUNT} internet=${enableInternet} ${inactivityTimeoutMs}`,
);
},
async destroyWarmContainer() {
calls.push("destroy");
Expand All @@ -36,22 +40,25 @@ describe("createWorkspaceWarmPoolRuntime", () => {
await runtime.startContainer("warm-a");
await expect(runtime.isContainerRunning("warm-a")).resolves.toBe(true);

expect(calls).toEqual(["start 8080 /workspace shim 120000", "healthy"]);
expect(calls).toEqual(["start 8080 /workspace shim internet=true 120000", "healthy"]);
});

test("retries Workspace container placement while waiting for health", async () => {
const { startWorkspaceContainerAndWait } = await import("./computer-container-pool");
const calls: string[] = [];
let healthAttempts = 0;
const container = {
running: false,
// Stands in for the workspace container API rather than
// ctx.container: the warm start goes through the API so the launch
// carries the shared secret and is recorded for adoption.
const api = {
async setInactivityTimeout(durationMs: number) {
calls.push(`timeout ${durationMs}`);
},
start() {
async start() {
calls.push("start");
return { runtimeId: "runtime", clientSecret: "secret", outcome: "launched" as const };
},
getTcpPort() {
port() {
return {
async fetch() {
healthAttempts += 1;
Expand All @@ -61,17 +68,18 @@ describe("createWorkspaceWarmPoolRuntime", () => {
"There is no container instance that can be provided to this Durable Object, try again later",
);
}
container.running = true;
return new Response(null, { status: 200 });
},
} as unknown as Fetcher;
},
};

await startWorkspaceContainerAndWait(container, { PORT: "8080" }, 120_000, {
attempts: 3,
wait: async () => {},
});
await startWorkspaceContainerAndWait(
api,
{ env: { PORT: "8080" }, enableInternet: true },
120_000,
{ attempts: 3, wait: async () => {} },
);

expect(calls).toEqual([
"timeout 120000",
Expand Down
56 changes: 34 additions & 22 deletions examples/think-compare-runtimes/worker/computer-container-pool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DurableObject } from "cloudflare:workers";
import {
type ContainerLaunchSpec,
type IWorkspaceContainerAPI,
withWorkspaceContainer,
} from "@cloudflare/computer/backends/container";
Expand All @@ -17,7 +18,7 @@ export interface WorkspacePoolEnv extends ContainerPoolConfigEnv {

export interface WorkspaceContainerHostHandle {
getWorkspaceContainer(): IWorkspaceContainerAPI | Promise<IWorkspaceContainerAPI>;
startWarmContainer(env: Record<string, string>, inactivityTimeoutMs: number): Promise<void>;
startWarmContainer(spec: ContainerLaunchSpec, inactivityTimeoutMs: number): Promise<void>;
destroyWarmContainer(): Promise<void>;
isWarmContainerHealthy(): Promise<boolean>;
}
Expand All @@ -35,11 +36,12 @@ class WorkspaceContainerHostBase extends withWorkspaceContainer(
) {}

export class WorkspaceContainerHost extends WorkspaceContainerHostBase {
async startWarmContainer(
env: Record<string, string>,
inactivityTimeoutMs: number,
): Promise<void> {
await startWorkspaceContainerAndWait(this.getContainer(), env, inactivityTimeoutMs);
async startWarmContainer(spec: ContainerLaunchSpec, inactivityTimeoutMs: number): Promise<void> {
// Through the workspace API rather than ctx.container, so the launch
// carries whatever the API adds — today the shared secret the
// daemon's HTTP surface requires — and is recorded, so the workspace
// that adopts this container can tell it matches.
await startWorkspaceContainerAndWait(this.getWorkspaceContainer(), spec, inactivityTimeoutMs);
}

async destroyWarmContainer(): Promise<void> {
Expand Down Expand Up @@ -69,7 +71,7 @@ export function createWorkspaceWarmPoolRuntime(env: WorkspacePoolEnv): WarmPoolR
async startContainer(containerId) {
const host = getWorkspaceContainerHost(env, containerId);
try {
await host.startWarmContainer(workspaceContainerEnv(env), containerSleepAfterMs(env));
await host.startWarmContainer(workspaceLaunchSpec(env), containerSleepAfterMs(env));
} catch (error) {
console.warn({
message: "Workspace warm container failed to start",
Expand Down Expand Up @@ -103,19 +105,28 @@ function getWorkspaceContainerHost(
) as unknown as WorkspaceContainerHostHandle;
}

function workspaceContainerEnv(env: WorkspacePoolEnv): Record<string, string> {
function workspaceLaunchSpec(env: WorkspacePoolEnv): ContainerLaunchSpec {
return {
PORT: String(WORKSPACE_PORT),
MOUNT_POINT: "/workspace",
...(env.FUSE_MOUNT ? { FUSE_MOUNT: env.FUSE_MOUNT } : {}),
env: {
PORT: String(WORKSPACE_PORT),
MOUNT_POINT: "/workspace",
...(env.FUSE_MOUNT ? { FUSE_MOUNT: env.FUSE_MOUNT } : {}),
},
// A pool cannot know the egress policy of the workspace that will
// adopt a container, so this has to agree with it by configuration.
// Disagreeing costs a relaunch on adoption, not the policy: the
// adopting workspace compares this spec against its own and
// replaces the container rather than inheriting the wrong one.
enableInternet: true,
};
}

interface WorkspaceContainerControl {
readonly running: boolean;
// The subset of the workspace container API a warm start needs.
// Structurally satisfied by IWorkspaceContainerAPI.
interface WorkspaceWarmStartAPI {
setInactivityTimeout(durationMs: number): Promise<void>;
start(options: { enableInternet: boolean; env: Record<string, string> }): void;
getTcpPort(port: number): Fetcher;
start(spec: ContainerLaunchSpec): Promise<unknown>;
port(port: number): Fetcher;
}

interface WorkspaceStartWaitOptions {
Expand All @@ -124,22 +135,23 @@ interface WorkspaceStartWaitOptions {
}

export async function startWorkspaceContainerAndWait(
container: WorkspaceContainerControl,
env: Record<string, string>,
api: WorkspaceWarmStartAPI,
spec: ContainerLaunchSpec,
inactivityTimeoutMs: number,
options: WorkspaceStartWaitOptions = {},
): Promise<void> {
const attempts = options.attempts ?? 120;
const wait = options.wait ?? ((durationMs) => scheduler.wait(durationMs));
await container.setInactivityTimeout(inactivityTimeoutMs);
await api.setInactivityTimeout(inactivityTimeoutMs);

let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (!container.running) {
container.start({ enableInternet: true, env });
}
// Called unconditionally: start() adopts a container already running
// with this spec, so there is no need to check `running` first the
// way a raw ctx.container.start() did.
await api.start(spec);
try {
await waitForWorkspaceHealth(() => container.getTcpPort(WORKSPACE_PORT), 1);
await waitForWorkspaceHealth(() => api.port(WORKSPACE_PORT), 1);
return;
} catch (error) {
lastError = error;
Expand Down
Loading
Loading