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.

Browser IDE vs local IDE attach

There are two different “VS Code” surfaces:

  1. Browser VS Code. This is what Coppice ships today: code-server runs inside the sandbox and the gateway proxies the browser UI plus WebSocket traffic. It works for FreeBSD jails because the remote side is code-server, not Microsoft’s Remote-SSH server.
  2. Local VS Code Remote-SSH. This is the Daytona-style developer workflow: a user’s desktop VS Code connects to a sandbox with the Remote-SSH extension, installs VS Code Server on the remote host, and opens terminals/files there. Coppice now ships the first useful version for Linux/bhyve guests: POST /sandboxes/:id/ssh-access generates an expiring ed25519 key, injects it into the guest’s authorized_keys, returns a Remote-SSH config stanza, and removes the key after the TTL. FreeBSD jail templates still use the browser code-server path unless the template explicitly runs sshd.

This distinction matters on FreeBSD. Microsoft’s Remote-SSH support matrix is Linux/macOS/Windows-oriented, and the extension installs a remote VS Code Server on the target host. Browser code-server avoids that unsupported remote-server path while still giving an editor, terminal, extension host, and file tree inside the sandbox.

Local Remote-SSH attach for bhyve guests

The gateway route is intentionally scoped to SSH-backed bhyve templates such as debian-12-bhyve. It relies on the gateway’s existing control-plane SSH key to add a one-off public key to the guest, then returns the private half to the operator once:

POST /sandboxes/<id>/ssh-access?ttl=1h&jumpHost=honor

The response includes privateKey, sshConfig, sshCommand, and vscodeRemoteUri. The generated config uses ProxyJump honor, so a laptop that can SSH to honor can reach the guest address behind honor’s bhyve bridge. Receipt: benchmarks/results/ide-ssh-access/latest.txt creates a Debian bhyve sandbox, generates a five-second key, proves SSH succeeds with that key, waits for expiry, proves the same key no longer works, and deletes the sandbox.

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.