Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ceb2f6c
chore: Update capnweb to 0.10.0
aron-cf Aug 17, 2026
c54406b
computerd: Remove UPSTREAM_URL
aron-cf Aug 17, 2026
1a1998f
computerd: Take explicit paths in the /connect request
aron-cf Aug 17, 2026
ff2a618
computerd: Serve the capnweb session on /api
aron-cf Aug 17, 2026
3add88e
rpc: Remove serveHTTPBatch
aron-cf Aug 17, 2026
fd7789b
computer: Name the egress paths in the /connect request
aron-cf Aug 17, 2026
ad7e073
computer: Serve the capnweb session on /api
aron-cf Aug 17, 2026
f02bbec
examples: Route the capnweb upgrade on /api
aron-cf Aug 17, 2026
0f2ae74
script: Dial the capnweb session on /api
aron-cf Aug 17, 2026
eaa4638
script: Soak sync between a host and one computerd
aron-cf Aug 17, 2026
78e9e72
docs: Describe the single /api endpoint
aron-cf Aug 17, 2026
76863af
chore: Add a changeset for the /api endpoint move
aron-cf Aug 17, 2026
64df435
computer: Rename the tokenized callback segment to /api
aron-cf Aug 17, 2026
78cd36b
rpc: Refer to the endpoint as /api in comments
aron-cf Aug 17, 2026
3ff0c63
docs: Drop the HTTP batch alternative from the wire description
aron-cf Aug 17, 2026
b7af944
script: Dial the capnweb session on /api in exec-tests
aron-cf Aug 17, 2026
fcb79cf
script: Fix the paths and result shape in the FUSE flush check
aron-cf Aug 17, 2026
5c2393e
script: Run the whole exec-tests suite outside workerd
aron-cf Aug 17, 2026
c202552
chore: Reword the changeset for the endpoint move
aron-cf Aug 17, 2026
2132e87
docs: Drop the missing-HTTP-transport note from the carrier
aron-cf Aug 17, 2026
712f041
script: Pair each soak file with its own payload
aron-cf Aug 17, 2026
ae09ee0
chore: Split the changeset into one entry per change
aron-cf Aug 17, 2026
16eacf8
computerd: Move the watermarks route under /api
aron-cf Aug 18, 2026
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
12 changes: 7 additions & 5 deletions .agents/skills/capnweb/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ description: |
between the Durable Object and `computerd`. It's an object-capability RPC
system with promise pipelining, structured-clone-style transfer of
stubs, and bidirectional calls. The wire format used here is text
JSON over a long-lived WebSocket, with an HTTP batch alternative.
JSON over a long-lived WebSocket. That is the only carrier: capnweb's
HTTP batch transport cannot deliver a stream returned from a call,
which is what every read on this interface is.

## Where things live

Expand Down Expand Up @@ -48,10 +50,10 @@ you don't explicitly dispose stubs, you leak resources on the other
side of the connection.

This matters more here than in many capnweb deployments because the
connection is **long-lived**. HTTP batch sessions auto-dispose
everything when the batch ends, but our WebSocket between the
Durable Object and `computerd` stays up for the lifetime of the workspace.
Every undisposed stub stays alive until that connection drops.
connection is **long-lived**. A short-lived session disposes everything
when it ends; our WebSocket between the Durable Object and `computerd`
stays up for the lifetime of the workspace, so every undisposed stub
stays alive until that connection drops.

## The caller-disposes rule

Expand Down
14 changes: 8 additions & 6 deletions .agents/skills/debugging-computerd-fuse/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: debugging-computerd-fuse
description: Debug computerd in real-FUSE mode end-to-end without workerd, vitest-pool-workers, or wrangler in the loop. Boot the linux-x64 binary in a privileged docker container, drive its capnweb /ws endpoint from Node, simulate DO-side sync from a SQLiteTestStorage, and isolate FUSE-related deadlocks. Load when a real-FUSE bug reproduces locally but unit tests pass, when the harness vitest tests hang against a real container, or when you need to attribute a wedge to FUSE vs sync vs exec.
description: Debug computerd in real-FUSE mode end-to-end without workerd, vitest-pool-workers, or wrangler in the loop. Boot the linux-x64 binary in a privileged docker container, drive its capnweb /api endpoint from Node, simulate DO-side sync from a SQLiteTestStorage, and isolate FUSE-related deadlocks. Load when a real-FUSE bug reproduces locally but unit tests pass, when the harness vitest tests hang against a real container, or when you need to attribute a wedge to FUSE vs sync vs exec.
---

# Debugging computerd against real FUSE
Expand Down Expand Up @@ -87,10 +87,12 @@ with a missing `/dev/fuse` would have failed startup outright.

## Drive computerd from a Node script

computerd serves a composite `WorkspaceRPC` over `/ws` (capnweb WebSocket)
and `/api` (capnweb HTTP batch). The `@cloudflare/computer-rpc/client`
package wraps the WS form and the `/driver` subpath exposes
`pushOnce`/`pullOnce` against a Node-side `Database`.
computerd serves a composite `WorkspaceRPC` over `/api`, a capnweb
WebSocket. That is the only RPC carrier: capnweb's HTTP batch transport
cannot deliver a returned stream, which is what every read on this
interface is. The `@cloudflare/computer-rpc/client` package wraps the
socket and the `/driver` subpath exposes `pushOnce`/`pullOnce` against a
Node-side `Database`.

Set up a probe project once:

Expand Down Expand Up @@ -123,7 +125,7 @@ import { pullOnce, pushOnce } from "@cloudflare/computer-rpc/driver";
import { WebSocket } from "ws";

const url = process.env.COMPUTERD_URL; // e.g. http://127.0.0.1:18080
const wsUrl = `${url.replace(/^http(s?):\/\//, "ws$1://")}/ws`;
const wsUrl = `${url.replace(/^http(s?):\/\//, "ws$1://")}/api`;

const storage = new SQLiteTestStorage();
const db = new Database(storage);
Expand Down
5 changes: 5 additions & 0 deletions .changeset/connect-names-host-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

The /connect caller now provides both API & healthcheck endpoints. The container no longer builds either path itself, so a host is free to serve them wherever it likes.
5 changes: 5 additions & 0 deletions .changeset/remove-upstream-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

The UPSTREAM_URL environment variable has been removed along with the container's own sync loop. Syncing is driven by whichever peer holds the other end of the Cap'n Web session.
5 changes: 5 additions & 0 deletions .changeset/rename-ws-endpoint-to-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

The Cap'n Web /ws endpoint has been renamed to /api at both ends of the connection. A durable object that routes the container's outbound upgrade must match /api in its own fetch handler. Support for the Cap'n Web HTTP batch transport has been removed, so /api carries a websocket only.
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,9 @@ loop. Reach for these when you're chasing a behavior the unit tests don't cover.
- `shell` boots a debian-slim container with the linux `computerd` binary
mounted under `/usr/local/bin`. The starting point for anything
that needs a real FUSE mount.
- `computerd-soak.mjs` boots two `computerd` containers wired peer-to-peer and
soaks the sync loop. Use it to chase convergence or churn bugs.
- `computerd-soak.mjs` boots one `computerd` container, takes the host's
part of the sync loop from a local store, and reports convergence lag
beside container memory. Use it to chase convergence or churn bugs.
- `computerd-stub-soak.mjs` soaks the long-lived WebSocket session and
reads `session.getStats()` to detect stub-disposal drift. Run it
for changes around the capnweb lifecycle.
Expand All @@ -147,6 +148,10 @@ loop. Reach for these when you're chasing a behavior the unit tests don't cover.
tasks against the mount with a tmpfs baseline for comparison.
- `exec-tests` boots `computerd` in docker with FUSE disabled and exercises
a few `shell.exec` scenarios.
- `lib/cloudflare-workers-stub.mjs` is a node `--import` hook that stubs
the `cloudflare:workers` module, so a host-side script can import
`@cloudflare/computer`'s main entry outside workerd. Not runnable on
its own; `exec-tests` uses it.
- `npm-bench.sh` / `run-npm-bench.sh` benchmark npm package installs
on native disk vs the FUSE mount. `run-npm-bench.sh` is the
user-facing entry point that boots a privileged Docker container;
Expand Down
30 changes: 17 additions & 13 deletions docs/07_injected_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ staging the binary into a container image.
same paths. The backend is picked by `FUSE_MOUNT` (default `auto`,
see the env-var table below).
2. **Dirty tracking.** Writes that flow through FUSE land in the
in-container VFS database; sync (when `UPSTREAM_URL` is set) is
what surfaces those revisions back out. See doc 02 for the sync
protocol.
in-container VFS database; the host pulls those revisions back out
across the capnweb session. See doc 02 for the sync protocol.
3. **Exec.** Runs shell commands and streams stdout/stderr back over
capnweb. See [05. Shell Interface](./05_runtime_interface.md).
4. **Apply.** Accepts changes pushed by the DO and writes them into
Expand All @@ -52,11 +51,15 @@ backend pins it to `8080`) and serves:
| --- | --- | --- |
| `/health` | `GET`, `HEAD` | Liveness probe; `200 ok\n` as soon as the HTTP server binds. |
| `/__computerd/info` | `GET` | Runtime info: FUSE backend, mount point, port. |
| `/api` | `POST` | HTTP-batch capnweb transport. |
| `/ws` | `GET` (upgrade) | WebSocket capnweb transport — the bootstrap stub is `WorkspaceRPC`. |
| `/connect` | `POST` | Tells `computerd` to dial *out* to a caller-supplied URL and serve a `WorkspaceRPC` session over that outbound WebSocket. Used by the Cloudflare backend (see below). |
| `/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. |
| `/connect` | `POST` | Tells `computerd` to dial *out* to a caller-supplied endpoint and serve a `WorkspaceRPC` session over that outbound WebSocket. Used by the Cloudflare backend (see below). |
| `/` | `GET` | Banner/info page. |

`/api` is the workspace surface: the session itself, plus anything that
reads through it. `/__computerd` is daemon introspection, which is why
runtime info sits there and revisions do not.

The capnweb bootstrap interface is **`WorkspaceRPC`** (defined in
`packages/rpc/`), split into `sync` and `shell` sub-stubs.

Expand Down Expand Up @@ -108,9 +111,9 @@ Provider-agnostic shape — three steps, in order:
path the mount is awaited *before* `listen`, so by the time
`/health` answers FUSE is up too. With `FUSE_MOUNT=none` there is
no FUSE step at all.
3. **Open the capnweb session.** Either the host upgrades to `/ws`
directly, or it asks `computerd` (via `POST /connect`) to dial *out* to a
URL it controls and serve the session over that outbound socket.
3. **Open the capnweb session.** Either the host upgrades to `/api`
directly, or it asks `computerd` (via `POST /connect`) to dial *out* to an
endpoint it controls and serve the session over that outbound socket.
Either way, the bootstrap stub is `WorkspaceRPC`.

### Cloudflare Containers specifics
Expand All @@ -132,8 +135,10 @@ wires it like this:
repeated until it returns `200`.
4. **Invert the WebSocket.** The DO arms an upgrade slot
(`#armUpgrade`) and then `POST`s to `/connect` on the container
(`#postConnect`). `computerd` reads that request and dials *out* to the
egress at `${egressHost}/ws`. Because the egress is intercepted,
(`#postConnect`). The request names the egress base and both paths,
so `computerd` polls `base + health` and then dials `base + api`;
the daemon assembles no paths of its own. Because the egress is
intercepted,
that outbound dial loops back to the DO's `handleFetch()`, which
accepts the upgrade and resolves the in-flight `#pendingUpgrade`.
The capnweb session then runs over that socket. **The WebSocket
Expand All @@ -154,10 +159,9 @@ These are the variables `computerd` actually consumes (see

| Variable | Default | Meaning |
| --- | --- | --- |
| `PORT` | `45678` | Port the HTTP/WS server listens on. CF backend pins this to `8080`. |
| `PORT` | `45678` | Port the HTTP server listens on. CF backend pins this to `8080`. |
| `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. |
| `UPSTREAM_URL` | unset | If set, `computerd` starts a sync client against this URL to push/pull VFS revisions. |
| `EXEC_LOG_MAX_BYTES` | runner default | Caps the per-exec stdout/stderr log retained in-memory. |
| `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. |

Expand Down
10 changes: 4 additions & 6 deletions docs/08_capnweb_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@

[capnweb](https://github.com/cloudflare/capnweb) is the RPC framing used
between the Durable Object and the in-container `computerd` computerd.
The wire format is text JSON over a single WebSocket (with an HTTP-batch
alternative). The interface served is `WorkspaceRPC`, defined in
The wire format is text JSON over a single WebSocket. The interface
served is `WorkspaceRPC`, defined in
`packages/rpc/src/interface.ts` and consumed by both sides.

## Transport

- **Carrier.** One long-lived WebSocket per Workspace. The DO opens it
against the computerd's `/ws` endpoint, with `/api` available
as an HTTP-batch alternative (single POST per call) for callers that
can't hold a socket. Default port is `45678`; it will become a
- **Carrier.** One long-lived WebSocket per Workspace, on the
computerd's `/api` endpoint. Default port is `45678`; it will become a
build-time variable so hosts can pin a non-default port.
- **Framing.** capnweb text frames. Binary frames are unsupported.
**(planned)** the server will fail the session loudly on the first
Expand Down
14 changes: 7 additions & 7 deletions docs/11_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ capnweb WebSocket session.
│ │ Workspace │ │ │ │ computerd │ │
│ │ fs: WorkspaceFS │ │ │ │ HTTP server │ │
│ │ shell: ShellRPC │ │ │ │ /health │ │
│ │ sync: SyncRPC │◀─┼── capnweb WS ───▶│ │ /connect /ws │ │
│ │ sync: SyncRPC │◀─┼── capnweb WS ───▶│ │ /connect /api │ │
│ │ push() / pull() │ │ │ │ FUSE mount │ │
│ │ ready() │ │ │ │ exec runner │ │
│ └──────────┬──────────┘ │ │ └────────┬─────────┘ │
Expand All @@ -50,7 +50,7 @@ The 1:1 mapping is load-bearing for several reasons:
doesn't have to multiplex multiple WS peers.

The DO is the WebSocket *server* in this pairing, even though `computerd`
exposes its own `/ws` server-side and could be dialed directly. The
exposes its own `/api` server-side and could be dialed directly. The
reason for the inversion is documented in
[07. Injected Service §Bootstrap sequence](./07_injected_service.md):
the egress interceptor needs to be wired before any traffic flows, and
Expand Down Expand Up @@ -90,7 +90,7 @@ an incarnation boundary. What survives is:

On every new incarnation `Workspace.ready()` re-runs `#connect()`,
which re-enters the backend's bootstrap sequence. If the container is
still alive, the backend's `POST /connect` + `/ws` handshake produces
still alive, the backend's `POST /connect` + `/api` handshake produces
a fresh capnweb session against the same in-memory VFS on the
container side. If the container died too (e.g. host OOM took both),
the next sync round is a rev-0 baseline rebuild from the DO's store.
Expand Down Expand Up @@ -129,9 +129,9 @@ that point so the next call rebuilds from scratch (see the container host and ba

The critical asymmetry: the **container's VFS is process-lifetime
in-memory**, while the **DO's VFS is durable SQLite**. A container
restart loses container-side state. Sync via `UPSTREAM_URL` (which
the Cloudflare backend wires automatically) is what brings state back
on the next push/pull round.
restart loses container-side state. The durable object drives sync
across the capnweb session it opens through `POST /connect`, and that
is what brings state back on the next push/pull round.

## Capnweb lifecycle

Expand Down Expand Up @@ -373,7 +373,7 @@ between agent turns, and it's exactly where hibernation pays off.

Adopting PartySocket for reconnect/backoff would require the DO
to be the WebSocket *client* dialing `computerd`'s
`/ws` endpoint. That model is appealing for reconnect, but
`/api` endpoint. That model is appealing for reconnect, but
hibernatable WebSockets only work server-side via
`ctx.acceptWebSocket()` — there is no hibernation API for outbound
client sockets. **Inverting the dial direction permanently forecloses
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ To build the binary from source instead, run `npm run build:bin
`artifacts/computerd/computerd-linux-x64`, then `COPY` that into the
image.

`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what `examples/container/` uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `UPSTREAM_URL`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence.
`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what `examples/container/` uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence.

## Example

Expand Down
13 changes: 7 additions & 6 deletions examples/container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ client ─► Worker /c/<name>/{file,exec}
DO (ContainerExample) ──► Container ──► computerd (:8080)
▲ │
│ ws://computer.internal/ws
│ ws://computer.internal/api
└────────── capnweb session ◄──────┘
```

Expand All @@ -30,19 +30,20 @@ client ─► Worker /c/<name>/{file,exec}
`Workspace` instance. That backend owns the entire computerd lifecycle:
container start,
outbound egress interception, port-readiness polling, POST
`/connect` to computerd, `/ws` upgrade routing, and capnweb session
`/connect` to computerd, `/api` upgrade routing, and capnweb session
attach.
2. computerd reaches the Worker through the container's **outbound
interception** (`ctx.container.interceptOutboundHttp("computer.internal",
…)`, set up by the backend). The DO passes
`ctx.exports.WorkspaceProxy({ props: { binding, id } })` as the
egress fetcher; that `WorkerEntrypoint` (re-exported from
`@cloudflare/computer`) routes `/ws` upgrades back to the owning DO.
`@cloudflare/computer`) routes `/api` upgrades back to the owning DO.
3. When `Workspace.ready()` is called for the first time, the
backend posts `/connect` into computerd with
`{ url: "http://computer.internal" }`. computerd polls
`computer.internal/health`, then dials
`ws://computer.internal/ws`.
`{ base: "http://computer.internal", health: "/health", api: "/api" }`.
computerd polls `computer.internal/health`, then dials
`ws://computer.internal/api`. Naming both paths in the request keeps
the daemon from assembling routes it does not serve.
4. `WorkspaceProxy.fetch` forwards the upgrade to the DO's `fetch()`
via the DO binding looked up from its props. The DO's `fetch()`
delegates to `backend.handleFetch(req)`, which performs the
Expand Down
6 changes: 3 additions & 3 deletions examples/container/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// The DO is a thin shell over CloudflareContainerBackend: it picks
// the container (this.ctx.container) and the egress fetcher
// (ctx.exports.WorkspaceProxy bound to this DO instance), forwards
// container-bound /ws upgrades back through the backend, and
// container-bound /api upgrades back through the backend, and
// otherwise just calls into a single Workspace instance.
//
// Wire shape:
Expand All @@ -14,7 +14,7 @@
// ▼
// ContainerExample DO ──► Container ──► computerd (:8080)
// ▲ │
// │ ws://computer.internal/ws
// │ ws://computer.internal/api
// └─── capnweb session ◀─────────────┘

import { DurableObject, tracing } from "cloudflare:workers";
Expand Down Expand Up @@ -92,7 +92,7 @@ function workspaceOptions(self: InstanceType<typeof ContainerBase>): WorkspaceOp
// hands back round-trip into this DO; the actual SyncRPC + ShellRPC
// traffic stays on the computerd ↔ DO capnweb wire.
export class ContainerExample extends withWorkspace(ContainerBase, workspaceOptions) {
// ---- WebSocket: computerd's outbound /ws upgrade ---------------------
// ---- WebSocket: computerd's outbound /api upgrade --------------------

override async fetch(request: Request): Promise<Response> {
return this.backend.handleFetch(request);
Expand Down
2 changes: 1 addition & 1 deletion examples/mcp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("Computer Code Mode MCP", () => {
expect(health.status).toBe(200);
expect(await health.text()).toBe("ok\n");

const internal = await SELF.fetch("https://example.test/ws");
const internal = await SELF.fetch("https://example.test/api");
expect(internal.status).toBe(404);
});

Expand Down
2 changes: 1 addition & 1 deletion examples/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class ComputerMCP extends withWorkspace(ComputerMCPBase, workspaceOptions
const path = new URL(request.url).pathname;
// computerd reaches this callback through an internal binding. The public
// Worker forwards only /mcp.
if (path === "/ws") return this.containerShell.handleFetch(request);
if (path === "/api") return this.containerShell.handleFetch(request);
if (path !== "/mcp") return new Response("not found", { status: 404 });

const unauthorized = authorize(request, this.env.MCP_TOKEN);
Expand Down
Loading
Loading