Browser sandbox (Chromium via CDP)

A browser sandbox is headless chromium running in a cube you can POST to. The first attempt at #60 (late March) assumed the Playwright client had to run inside the sandbox, tried to install it, and died on the simple fact that Playwright ships no FreeBSD wheel. The retry — now that per-sandbox VNET landed in #69 — runs chromium in the jail and keeps the CDP client on the host. That sidesteps the wheel problem entirely, because a CDP client is an HTTP+WebSocket speaker, not a browser.

What the first attempt got wrong

The original sketch was “jail with chromium + Playwright + a little Python script that drives it.” Playwright felt natural: it’s the E2B Chromium-sandbox story verbatim, and rewriting the example in something else would be gratuitous.

Two problems, neither cosmetic.

First, no Playwright wheel on FreeBSD. The Python package’s PyPI release only ships Linux, macOS, and Windows wheels; there is no sdist that rebuilds from source because the package ships a large pre-built Node/Playwright-core bundle. pip install playwright on FreeBSD fails at resolution time with “no wheels with a matching platform tag.” We could have run a Linux compat shim or a Linuxulator jail, but both piles shadow the actual demo.

Second, even if Playwright installed, it would try to download its bundled chromium. That download resolves to a Linux binary that can’t run natively on FreeBSD. Another compat-layer detour.

The mistake was running Playwright in the same box as chromium. CDP is a remote protocol. The client doesn’t have to share an OS — or even an architecture — with the browser.

The new shape

    host (honor)                       jail (browser-demo, VNET)
    ------------                       -------------------------
    pychrome client  --- HTTP CDP -->  socat -> chromium [::1]:9222
                     --- ws     CDP -> (headless --no-sandbox)

Three moving parts:

  1. A ZFS-clone jail template zroot/jails/browser-template with chromium and socat installed.
  2. A bring-up script tools/coppice-browser-demo.sh that clones the template, attaches it to coppicenet0 at 10.78.0.251, starts headless chromium inside on :9222, and socat-proxies the jail’s external IP onto chromium’s [::1]:9222 listener (see “Quirks” below for why that detour exists).
  3. A host-side Python client (examples/09-browser-sandbox.py) that speaks CDP via pychrome — pure Python, so it installs on FreeBSD with one uv run —with pychrome. The client connects to http://10.78.0.251:9222, creates a tab, navigates to https://example.com, grabs the title, and dumps a PNG.

The jail is VNET. The bridge is coppicenet0 (10.78.0.1/24). The host-to-jail path is a single epair; the client-to-chromium path is one HTTP hop. There is no gateway involvement — the current e2b-compat clones a single template snapshot regardless of the requested templateID (per-template routing lives in #68), so the demo spins its own jail. Wiring this into the gateway-managed sandbox surface is a follow-up: one new template entry, one snapshot name, no lifecycle changes.

Quirks

Chromium 147 ignores --remote-debugging-address

--headless=new on chromium 147 binds the DevTools HTTP listener to [::1]:<port> regardless of the flag. It’s the same behavior reported upstream (issue 1485908 for the equivalent Linux symptom); the FreeBSD port inherits the same devtools_http_handler path. We didn’t dig into whether —headless (legacy) or a future patch fixes it — socat is a one-line workaround that’s cheap enough to keep even if chromium’s bind story changes.

Inside the jail:

socat -d TCP4-LISTEN:9222,bind=0.0.0.0,reuseaddr,fork \
         TCP6:[::1]:9222

The fork keeps socat alive across CDP’s many websocket-upgrade connections; bind=0.0.0.0 is important because the default would guess the loopback again.

lo0 is down inside a fresh VNET jail

FreeBSD VNET jails come up with lo0 in LOOPBACK,MULTICAST but not UP. Chromium’s first boot step tries to bind a localhost socket — that bind fails with “Can’t assign requested address” and chromium aborts with “Cannot start http server for devtools.” The fix is one line in the jail’s exec.start:

ifconfig lo0 up

Worth knowing if you ever find chromium refusing to start in a jail for no obvious reason.

kern.ipc.shm_allow_removed must be 1

The FreeBSD chromium port’s /usr/local/bin/chrome wrapper refuses to run unless kern.ipc.shm_allow_removed=1. This is a host-wide sysctl; the bring-up script sets it. Harmless on a dev host that isn’t running legacy SysV-shm code.

Security posture

Chromium runs with —no-sandbox. Chromium’s Linux sandbox uses user-namespaces + seccomp-bpf, neither of which exist on FreeBSD in the shape chromium expects. The port has a Capsicum-style story in flight but nothing is wrapping chromium with it today. The practical isolation boundary is the VNET jail itself: its own root filesystem (a ZFS clone), its own network stack, its own process space. A chromium exploit inside the jail has the same surface as any other in-jail RCE — pf anchors for the sandbox still apply, the host’s namespace and devfs are still separate, and jail -r still reaps the whole subtree.

This is a smaller attack surface than running chromium on the host without —no-sandbox. It’s a bigger one than what E2B gets on Linux. If you want to narrow it further, Capsicum-wrapping chromium is the next step; for the sandbox-of-sandboxes shape we ship today, the jail is the line.

Running it

Bring the demo jail up (once per session):

sudo tools/coppice-browser-demo.sh up

Run the demo:

uv run --with pychrome python3 examples/09-browser-sandbox.py

Sample transcript:

CDP up in 0.00s: Chrome/147.0.7727.101
  user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36
title:       'Example Domain'
screenshot:  /tmp/09-browser-example.png  (15189 bytes)
OK

Tear down:

sudo tools/coppice-browser-demo.sh down

Reference screenshot (780×493 PNG, 15 KB) at examples/fixtures/browser-example-screenshot.png.

Cross-refs