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; sync (when
UPSTREAM_URLis set) is what surfaces those revisions back out. 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. |
/__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). |
/ |
GET |
Banner/info page. |
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
/wsdirectly, or it askscomputerd(viaPOST /connect) to dial out to a URL 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.
container.start({ enableInternet, env })on the Cloudflare Containers API — not the@cloudflare/sandboxSDK. Idempotence comes fromcontainer.runningplus a cached#handle; 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. - 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).computerdreads that request and dials out to the egress at${egressHost}/ws. 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.- A
#monitoringflag watches container exit and drops the cached handle so the next call rebuilds from scratch. - No transparent reconnect after a mid-session drop. If the
WebSocket dies, the caller is expected to reconstruct the
Workspacerather than the backend trying to splice a new socket into the existing session.
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/WS 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. |
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 DO restarts — the
sandbox container is reaped only when its lifetime policy says so,
and a fresh DO incarnation reconnects to the same running daemon over
a new WebSocket (the Cloudflare backend's #monitoring flag drops the
cached handle if the container itself exits, forcing a rebuild).
Caveat: no on-disk persistence yet (packages/computerd/README.md).
The "same in-memory VFS across DO restarts" picture only holds while
the container process is alive. A container restart loses VFS state;
sync via UPSTREAM_URL is what brings state back across container
restarts.
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.