Demo portal (shell + browser)

The gateway has always exposed a clean JSON surface — good for the E2B SDK, awkward for a human driving a demo. #71 adds a portal: a single static page at /ui/ with a tab per sandbox, a terminal attached to each, and a status bar wired to the existing per-sandbox metrics. It is intentionally small. No build step, no framework, one HTML file and one JS file.

What it is

Point a browser at http://<gateway>:3000/ui/ after starting the gateway with --ui-dir e2b-compat/ui. You get:

There is no auth. The portal is a tunnelled-localhost demo surface, not a production control plane. Put it behind ssh -L or a real IDP if it leaves the bench.

What changed in the gateway

Two pieces of plumbing moved. First, the WebSocket handler was rewritten. The previous ws.rs piped child stdio and exchanged line-buffered JSON frames — usable for a test harness, unusable as an interactive terminal. The new version opens a pty via portable-pty, runs the reader on a blocking thread, serialises the writer behind a mutex, and handles {"type":"resize","cols":N,"rows":M} control frames so xterm.js’s FitAddon keeps the pty in step with the viewport. TERM=xterm-256color is set in the child so prompts and colour rendering work on first login.

Second, a static-file route. --ui-dir <path> (or E2B_COMPAT_UI_DIR=…) nests a tower-http ServeDir under /ui/. Unset, the route is absent — production gateways without a bundled SPA stay minimal. A /ui redirect covers the no-trailing-slash typo.

Restty: delivery and pinning

#72 swapped the terminal surface from xterm.js to restty — libghostty-vt + WebGPU, bundled as a single ESM file with the libghostty-vt WASM payload embedded as base64. The reasons were concrete: xterm.js’s wide-char and OSC-8 support is perennially close-but-not-right against Ghostty’s parser, and the portal’s “split a pane” request was going to mean re-implementing what Ghostty already does natively. Swapping to libghostty-vt (via restty) gets us the exact same terminal emulation that the native terminal ships, plus a built-in split manager, for roughly the same bundle size.

The library is loaded from esm.sh at a hard pin:

import { Restty, parseGhosttyTheme } from "https://esm.sh/restty@0.1.35";

We pin deliberately — the restty README flags the API as “may still change”, and esm.sh happily serves whatever semver the URL resolves to. @0.1.35 is the version we’ve tested through. Bumping is a conscious edit, not a passive CDN refresh. The WASM payload travels inline with the ESM, so one network fetch covers the whole library; the portal doesn’t configure a second origin or a fallback. A production deployment that can’t reach esm.sh should vendor the module — copy the pinned restty@0.1.35 ESM into e2b-compat/ui/vendor/ and change the import URL to ./vendor/restty.js. That’s a follow-up, not a blocker: the honor bench box has outbound HTTPS.

A Ghostty theme string drives the colours (see COPPICE_THEME in app.js), parsed by the library’s parseGhosttyTheme() so we stay on the blessed colour model rather than poking Config internals. Font is the library’s default JetBrains Mono preset at 13 px; overriding the family would need fontSources entries pointing at a hosted WOFF2.

Deploying to honor

mise run e2b:sync-honor
mise run e2b:build-honor
# then on honor, add --ui-dir to the gateway's start line:
sudo -E e2b-compat \
  --listen 0.0.0.0:3000 \
  --envd-listen 0.0.0.0:49999 \
  --envd-listen-base 0.0.0.0:49983 \
  --ui-dir /tmp/e2b-compat-src/e2b-compat/ui

From a dev machine, tunnel and open:

ssh -L 3000:localhost:3000 honor
# then browse http://localhost:3000/ui/

benchmarks/rigs/ui-smoke.sh is the canonical “did we break the static surface” check — it curls the three asset paths and asserts each returns 200 with the right needle in the body.

Browser tabs (v1)

#71 v1 enables the browser option in the + menu. Picking it issues POST /sandboxes {"templateID":"browser","cpuCount":100,"memoryMB":512}, which — after #70 landed the template registry — clones zroot/jails/browser-template@base and stands up a VNET jail with chromium and socat preinstalled. The UI then runs three steps in the background while a spinner covers the tab body:

  1. Launch chromium. POST /sandboxes/:id/exec with a small sh -c that brings up lo0, starts /usr/local/bin/chrome --headless=new --remote-debugging-port=9222, and backgrounds a socat TCP4-LISTEN:9222 … TCP6:[::1]:9222 trampoline. The socat step is load-bearing on FreeBSD: chromium 147 ignores --remote-debugging-address and pins CDP to [::1], so external reach from the bridge requires the ipv4↔ipv6 hop.
  2. Wait for CDP. Poll GET /cdp-proxy/:id/json/version every 500 ms, up to 30 s. A 200 means chromium bound the port and the devtools HTTP surface is live; anything else keeps waiting. A 502 from the proxy is distinct from a 404 — the former means “sandbox exists, CDP not up yet”, the latter means “no such sandbox”.
  3. Open devtools. GET /cdp-proxy/:id/json returns the list of targets; the UI picks the first type:"page" entry and points an <iframe> at /cdp-proxy/:id/devtools/inspector.html?ws=… with the ws param forced through the gateway proxy so devtools doesn’t try to dial the (unreachable from the operator’s browser) jail IP directly.

The CDP proxy

The new handler at src/cdp_proxy.rs fronts both the HTTP and WS surfaces of the in-jail chromium. One axum route — GET /cdp-proxy/:id/*path — covers both; the handler sniffs for a Connection: upgrade + Upgrade: websocket pair and branches to tokio-tungstenite for ws, or to reqwest for plain HTTP. JSON response bodies get a small rewrite pass: any ws://<jail_ip>:9222/devtools/… URL becomes ws://gateway/cdp-proxy/<id>/devtools/…, and the UI patches the literal gateway to the current page’s host before handing the URL to devtools. That keeps the proxy free of Host-header trust while still delivering a URL that works from the operator’s laptop.

Errors out of the proxy are narrow:

v1 limits

Roadmap past v1

Files