Architecture
ADR 0012: Chrome's debugging port never leaves the container unauthenticated
Status: accepted, 26 Sep 2026 · Workstream: computers · Spec: section 7.9 · Amends ADR 0003's port table
The problem
Chrome's remote-debugging protocol (CDP) is the whole surface hands-chrome drives a computer
with: navigate, click, type, read the page, run arbitrary JavaScript. It has no authentication of
its own: whoever can open a WebSocket to it can do all of that, including read cookies and any
value the Vault autofilled into a form.
The image (core/computers/image/) started Chrome with --remote-debugging-address=127.0.0.1,
correctly loopback-only, but then ran socat TCP-LISTEN:9223,fork TCP:127.0.0.1:9222 to relay it
out to a port of its own, published in ADR 0003's table so vyred's computers.endpoint could hand
hands-chrome a bare cdp: "http://host:9223". That relay put the whole unauthenticated protocol on
the internal Docker network, reachable not just by vyred but by anything else on it, in
particular another agent's own container. Found in glass's review of ADR 0003.
Decision
Chrome's debugging port is never reachable off 127.0.0.1 inside the container, full stop. The
only thing that ever dials it is computerd, which already answers every other route (/health,
/apps, /tree, /act, /input, /screenshot) behind Authorization: Bearer <COMPUTERD_TOKEN>
on port 7000. It grows two more routes on that same door:
GET /cdp/json/version, bearer-checked like every other route: proxies Chrome's own/json/versionand rewriteswebSocketDebuggerUrlfromws://127.0.0.1:9222/...tows://<host>/cdp/..., where<host>is whatever the caller used to reach computerd (req.headers.host): exactly the address it can dial next, real container or the fake driver's local mode alike.- A WebSocket upgrade at
/cdp/...: once its own check passes, a dumb authenticated pipe:net.connectto127.0.0.1:9222, replay the browser's own upgrade request with the/cdpprefix stripped andHostrewritten to Chrome's loopback address, then splice the two sockets. Nothing here parses CDP; gating happens once, at the door, the same as every other route.
A plain WebSocket (the one modules/hands-chrome/cdp.js uses, matching the browser-standard
API rather than a library with a headers option) cannot send an Authorization header, which is
the one thing every other computerd route relies on. So the upgrade's own check reads the token
from ?token= on the URL instead. cdp.js appends it after fetching the (header-authenticated)
/json/version, and it is scrubbed from every error the same as the bearer token is everywhere
else in this codebase.
core/computers/driver/index.js's PORTS drops cdp entirely: there is no longer a port for it.
pool.js's computers.endpoint stops handing out a raw cdp URL, only helper: {url, token},
and cdp.js builds ${helper.url}/cdp itself. The image stops installing and running socat;
EXPOSE drops to 5900 (Xvnc) and 7000 (computerd) only.
Consequences
- One more hop per CDP call's connection setup (computerd proxies the WebSocket instead of Chrome
answering it directly), paid once per computer, not once per call. That is the whole reason
cdp.jskeeps one connection open and reused (its own header comment, "the 137x finding"). - computerd is now in the request path for every CDP byte, not just discovery. Its own crash or restart now also drops a live CDP session, where before only Chrome's own crash did. Restarting computerd is expected to be rare (it is the container's PID-1-adjacent foreground process); if this turns out to matter in practice, the fix is computerd surviving its own restart without taking the proxy down, not moving CDP back off the authenticated path.
- Unvalidated like the rest of the image: the raw-socket upgrade replay (stripping
/cdp, rewritingHost, forwardinghead) is written against documented HTTP/WebSocket upgrade mechanics, not run against a live Chromium. First things to check once a container boots: that Chromium's ownHostcheck on the debugging port accepts127.0.0.1:9222from a proxied connection the way it would a direct one, and that GET/json/version's exact JSON shape matches whatcdpVersion()assumes.