Note
This doc now reflects shipped code in packages/computerd/ and
packages/computer/src/backends/. Items marked (planned) are
deferred work.
The "injected service" is the workspace daemon that runs inside the sandbox container. It owns the FUSE mount, the in-container VFS, the exec runner, and the capnweb RPC endpoint the DO talks to.
The package ships it as a single self-contained Node SEA binary —
computerd — produced by packages/computerd/ (npm package
@cloudflare/computerd, bin name computerd). The binary embeds Node,
the fuse-native prebuilds, and libfuse as SEA assets, so the host
image does not need a Node runtime. Build it with:
npm run build:bin --workspace @cloudflare/computerd
# → artifacts/computerd/computerd-linux-x64
# → artifacts/computerd/computerd-macos-x64examples/container/Dockerfile is the canonical recipe for
staging the binary into a container image.
- FUSE mount. Mounts the in-container VFS at
MOUNT_POINT(default/workspace) so any tool that runs inside the container — node, shells, compilers — sees the same tree the DO sees, with the same paths. The backend is picked byFUSE_MOUNT(defaultauto, see the env-var table below). - Dirty tracking. Writes that flow through FUSE land in the in-container VFS database; the host pulls those revisions back out across the capnweb session. See doc 02 for the sync protocol.
- Exec. Runs shell commands and streams stdout/stderr back over capnweb. See 05. Shell Interface.
- Apply. Accepts changes pushed by the DO and writes them into the VFS, suppressing its own dirty-tracking so deletes don't bounce back.
- Health. Exposes
GET /healthso the host-side workspace can probe for readiness before opening the RPC connection.
computerd listens on a single port (default 45678; the Cloudflare
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. 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. |
/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.
The canonical recipe is examples/container/Dockerfile:
FROM --platform=linux/amd64 debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fuse3 libfuse2t64 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY build/computerd-linux-x64 /usr/local/bin/computerd
RUN chmod +x /usr/local/bin/computerd
ENV PORT=8080
ENV MOUNT_POINT=/workspace
ENV FUSE_MOUNT=auto
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/computerd"]Notes:
- No Node, no
npm install, nopackage.json— the SEA binary embeds everything. libfuseitself is bundled into the binary as a SEA asset; the apt install offuse3 libfuse2t64provides the userland tooling and/dev/fuseplumbing for the host kernel.EXPOSE 8080matches the Cloudflare backend's pinned port. If you runcomputerdoutside Cloudflare Containers, leavePORTunset (default45678) or pick your own.- The port is currently hard-coded in code via
DEFAULT_PORT; making it a build-time variable is on the roadmap (planned).
Provider-agnostic shape — three steps, in order:
- Start the binary. The host-side workspace asks its sandbox
provider to launch
computerdas the container's entrypoint. - Poll the health endpoint. The host issues
HEAD /healthuntil it returns200. Caveat:/healthis wired by the HTTP server and answers200as soon as the socket binds. In the FUSE-enabled path the mount is awaited beforelisten, so by the time/healthanswers FUSE is up too. WithFUSE_MOUNT=nonethere is no FUSE step at all. - Open the capnweb session. Either the host upgrades to
/apidirectly, or it askscomputerd(viaPOST /connect) to dial out to an endpoint it controls and serve the session over that outbound socket. Either way, the bootstrap stub isWorkspaceRPC.
CloudflareContainerBackend (packages/computer/src/backends/container/cloudflare-container.ts)
wires it like this:
-
Start.
WorkspaceContainerAPI.start({ env, enableInternet }), which reaches the Cloudflare Containers API — not the@cloudflare/sandboxSDK. There is no process-name registry, nostartProcess/getProcess, and nonode /app/...command (the container'sENTRYPOINTrunscomputerddirectly).containerEnvpinsPORT=8080and lets the image's ownFUSE_MOUNTvalue (typicallyauto) win, and the API addsRPC_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.
-
Wire egress.
container.interceptOutboundHttp(egressHost, egress)routes outbound HTTP from the container ategressHostback to a WorkerFetcherthe DO controls. -
Probe.
container.getTcpPort(containerPort).fetch("/health", { method: "HEAD" }), repeated until it returns200. -
Invert the WebSocket. The DO arms an upgrade slot (
#armUpgrade) and thenPOSTs to/connecton the container (#postConnect). The request names the egress base and both paths, socomputerdpollsbase + healthand then dialsbase + api; the daemon assembles no paths of its own. Because the egress is intercepted, that outbound dial loops back to the DO'shandleFetch(), which accepts the upgrade and resolves the in-flight#pendingUpgrade. The capnweb session then runs over that socket. The WebSocket carrier is inverted versus a naive "host dials into container" model.
Sharp edges actually present in cloudflare-container.ts:
#armUpgrademust be set up before#postConnect, becausecomputerdcan dial back before thePOST /connectresponse returns.- The container host records each monitored generation's exit reason. The dead container closes its WebSocket, and
fetchPort()also short-circuits later requests with a transport error; either path invalidates the matching Workspace handle. - Reconnect replaces the whole session. If the WebSocket dies,
Workspaceinvalidates and closes the matching backend handle, then callsCloudflareContainerBackend.connect()again. The replacement runs the complete start, egress-interception, health,/connect, and reverse-WebSocket sequence; the backend never splices a new carrier into the dead capnweb session. Replay-safe sync and process lifecycle operations get one retry. Command spawn is retried only when no request was dispatched.
These are the variables computerd actually consumes (see
packages/computerd/src/cli/computerd.ts and packages/computerd/src/fuse/backend.ts):
| Variable | Default | Meaning |
|---|---|---|
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. |
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
addition to stdout/stderr. See "Failure handling" below for the crash
handlers that share the same logger.
Today:
computerdinstallsuncaughtExceptionandunhandledRejectionhandlers viainstallLogging()(incli/logger.ts). Each handler writes a formatted entry to the same logger —console.errorand, ifLOG_FILEis set, the file too — then callsprocess.exit(1).- Logs go to stdout/stderr by default. When
LOG_FILEis set, everyconsole.log/console.errorline is also appended to that file (open inO_APPENDmode, ISO-timestamped,[info]/[error]prefixed). No rotation; the operator is expected to manage the file. FUSE_MOUNT=fuse(ormacfuse) errors at startup if the corresponding kernel surface isn't available;FUSE_MOUNT=autosilently falls back to the userspace shim instead. The only "skip the mount entirely" path is the explicitFUSE_MOUNT=noneopt-out.
Planned:
- Soft-fail on FUSE-detect failure: the server still starts, exposes
RPC, and reports
fuseActive=falsevia/__computerd/info. Whether that includes a host-FS mirror for in-container writes is still open.
The computerd process is long-lived and outlives Durable Object restarts — the sandbox container is reaped only when its lifetime policy says so, and a fresh Durable Object incarnation reconnects to the same running daemon over a new WebSocket. The container monitor and transport error classifier drop stale handles so an operation can reconnect through the readiness gate.
Caveat: no on-disk persistence yet (packages/computerd/README.md). The same in-memory VFS across Durable Object restarts only holds while the container process is alive. A container restart loses VFS state. Watermark reconciliation and the next push rebuild the mirror from Durable Object storage, but reconnect cannot recover container-local files that were never pulled before the process died.
These behaviours aren't fully specified yet. File an issue if your use case depends on a particular resolution.
- Connection auth. Today the WebSocket endpoint trusts anything that can reach the port. On Cloudflare Containers that's safe because only the owning DO can reach the container's TCP port, but the moment we support providers with broader network exposure the server needs its own auth on the RPC handshake. Candidates: a short-lived shared secret minted by the workspace and passed via an env var, a per-connection challenge, or an mTLS client cert provisioned at boot. The wire surface (08. Capnweb Interface) will need a hello/auth phase before the bootstrap stub is exposed.
- Process user and file ownership.
computerdcurrently runs as whatever user the sandbox image'sENTRYPOINTruns as — typicallyroot, which is a poor default for a process that mounts FUSE and spawns arbitrary shell commands. The intent is to runcomputerdas an unprivileged user so a misbehaving exec can't escalate, but exec'd commands need to be able to read and write the FUSE-mounted tree. Open: which user owns the mount, what userexecruns as (workspace? per-exec dynamic?), and howallow_other/ setuid / shared-group ownership get wired so the two see the same files without opening the mount to every process in the container. - FUSE soft-fail behaviour. See "Failure handling" above —
whether the degraded
fuseActive=falsemode includes a host-FS mirror or just refuses container-side writes is unresolved.