A templateID=vscode sandbox boots a FreeBSD jail with
Coder’s code-server pre-installed. The demo portal’s
+ → vscode tab spawns the sandbox, jexecs code-server on
0.0.0.0:3333 inside the jail, and embeds a gateway-proxied
iframe at /vscode-proxy/<id>/. You get a real VS
Code window — editor, terminal, extensions, file tree — over
whatever browser can reach the gateway.
What makes this awkward on FreeBSD
code-server is a Node.js fork of VS Code. The npm install
path on FreeBSD dies twice over:
- postinstall wants Node 14 and yarn. The release source
tarball expects to run a yarn pass inside
lib/vscode/during install. yarn buildsargon2from source, which fails because the bundledbinding.gypis ancient enough to confuse modern node-gyp. Moderncode-server(≥ 4.x) wants a yarn pass regardless of native deps, because the vscode subtree’snode_modulesis not in the npm package — it ships only in the standalone release tarballs. - No FreeBSD binary. The project publishes Linux x64, Linux arm64, macOS x64, and macOS arm64 standalone tarballs. None FreeBSD. Linuxulator would run the bundled Linux node, but dragging linux-compat into every sandbox template is a heavier lift than necessary.
The recipe we landed on uses the Linux x64 standalone tarball (which
does ship a fully-populated node_modules tree),
throws away its bundled Linux node, and points the
shipped bin/code-server wrapper at FreeBSD’s native
node24 from ports. The script bin/code-server
is a small shell stub that ends with:
exec "$ROOT/lib/node" "$ROOT" "$@"
so the only surgery required is a symlink swap on
lib/node. Everything else is JavaScript and plain
data — no per-platform compilation to worry about.
The last friction is argon2. The prebuilt
argon2.node in the tarball is a Linux ELF that can’t
dlopen on FreeBSD (libm.so.6 doesn’t exist here). That
import is reached unconditionally by out/node/util.js.
Since code-server only calls argon2.hash and
argon2.verify when configured with
—auth password, and we always launch with
—auth none, we replace
node_modules/argon2/argon2.cjs with a throwing stub. The
module load path stays green; the functions are never invoked in
practice. A production deployment with password auth would need a
real FreeBSD argon2 (pure-JS fallback, or a native build)
— for a sandbox bound behind the gateway on a private VNET, the
jail itself is the boundary.
The full recipe lives in
tools/coppice-vscode-template.sh.
Idempotent; build is safe to re-run if the previous
attempt left a clone behind.
The gateway proxy
code-server binds on 0.0.0.0:3333 inside the jail. The
operator’s laptop has no route to 10.78.0.0/24, so the
gateway has to ferry both HTTP and the extension-host WebSocket
traffic. The browser sandbox
faced the same shape and solved it with
e2b-compat/src/cdp_proxy.rs, which rewrites
ws://<jail_ip>:9222/devtools/… URLs inside
response bodies so devtools dials back through the gateway. That
body-rewrite isn’t needed here — code-server emits only relative
URLs — so we factored out a thinner module,
e2b-compat/src/sandbox_proxy.rs, that passes bytes through
untouched. One route:
GET /vscode-proxy/:id/*path → HTTP + WS to 10.78.0.<M>:3333/*path
A single handler sniffs the Upgrade: websocket pair and
switches to WS bridging. The WS plumbing is the same
tokio_tungstenite + axum::extract::ws::WebSocket
pattern as cdp_proxy. Kept duplicated across the two
modules for now; if a third caller lands we’ll collapse both onto a
shared helper.
A 404 from the proxy means the sandbox id isn’t in
state.sandboxes. A 502 means the sandbox is known but
has no allocated IP — usually a gateway that restarted mid-wave and
didn’t reconstitute the allocator. freebsd_jail.rs has a
startup reconstitution pass for the common case; a 502 here is the
signal that the pass missed a jail.
The tab, end to end
- + → vscode clicks
POST /sandboxeswith{“templateID”:“vscode”,“memoryMB”:1024,“cpuCount”:200}. The gateway cloneszroot/jails/vscode-template@base, allocates a VNET IP, and hands back a sandbox id. - The UI posts to
/sandboxes/<id>/execwithcode-server —bind-addr 0.0.0.0:3333 —auth none /root/workspacewrapped in anohup … &so the exec call returns immediately. waitForVscodepolls/vscode-proxy/<id>/until it returns 200 or 302. Cold-start is ~3–5 seconds on honor; the timeout is 30 s to cover a warm-reboot template clone with headroom.- The iframe
srcflips to/vscode-proxy/<id>/?folder=/root/workspaceand the overlay fades. - Closing the tab issues
DELETE /sandboxes/<id>, which tears down the jail, releases the epair, destroys the ZFS clone, and releases the IP back to the allocator.
Per-jail memory cap is 1 GB — below that the TypeScript language
server occasionally OOMs on a medium repo. CPU cap is 200 (two full
cores). Both are rctl limits set at jail -c time; see
the create flow in freebsd_jail.rs::create_with_limits.
Security posture
—auth none is the safe choice inside a sandbox whose
only network path out is the gateway. The jail has no listener on
any public interface: 0.0.0.0:3333 binds inside the
jail’s own VNET, reachable only via coppicenet0 from
the host side, and the host-side pf anchor is empty-allow by default
(or default-deny if the caller asked for an air-gapped sandbox).
There’s no way in except through the gateway’s proxy, which means a
token would be a second lock on a door that’s already inside a vault.
For a production deployment — code-server as the entry point, not a
one-per-user demo — the obvious move is —hashed-password
with a real FreeBSD argon2, plus a reverse proxy in front that does
the auth-token dance. The stub in our template is pragmatically
fine; it’s the right call for the demo and an easy swap for a real
build. Same story as —no-sandbox in the browser tab:
the jail is the real isolation, so the inner-layer toggle that
exists for host-native deployments is doing nothing load-bearing
here.