VS Code remote (code-server via gateway proxy)

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:

  1. postinstall wants Node 14 and yarn. The release source tarball expects to run a yarn pass inside lib/vscode/ during install. yarn builds argon2 from source, which fails because the bundled binding.gyp is ancient enough to confuse modern node-gyp. Modern code-server (≥ 4.x) wants a yarn pass regardless of native deps, because the vscode subtree’s node_modules is not in the npm package — it ships only in the standalone release tarballs.
  2. 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

  1. + → vscode clicks POST /sandboxes with {“templateID”:“vscode”,“memoryMB”:1024,“cpuCount”:200}. The gateway clones zroot/jails/vscode-template@base, allocates a VNET IP, and hands back a sandbox id.
  2. The UI posts to /sandboxes/<id>/exec with code-server —bind-addr 0.0.0.0:3333 —auth none /root/workspace wrapped in a nohup … & so the exec call returns immediately.
  3. waitForVscode polls /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.
  4. The iframe src flips to /vscode-proxy/<id>/?folder=/root/workspace and the overlay fades.
  5. 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.