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:
- A ZFS-clone jail template
zroot/jails/browser-templatewithchromiumandsocatinstalled. - A bring-up script
tools/coppice-browser-demo.shthat clones the template, attaches it tocoppicenet0at10.78.0.251, starts headless chromium inside on:9222, and socat-proxies the jail’s external IP onto chromium’s[::1]:9222listener (see “Quirks” below for why that detour exists). - A host-side Python client
(
examples/09-browser-sandbox.py) that speaks CDP viapychrome— pure Python, so it installs on FreeBSD with oneuv run —with pychrome. The client connects tohttp://10.78.0.251:9222, creates a tab, navigates tohttps://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
- VNET jails — the #69 work that gave each sandbox a routable IP and unblocked this demo.
- Wildcard DNS — the host-side
plumbing that will let CDP listeners be reachable by
<port>-<sbx>.coppice.testonce#68lands per-template routing. - Parity gaps / Feature audit — the rows this appendix closes.