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, per-sandbox inner views, and a status bar wired to the existing metrics. The current implementation is the React + TypeScript portal in e2b-compat/ui-src/, built by Vite into e2b-compat/ui/ and served directly by the gateway at /ui/.

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

File browser (shell tabs)

Shell tabs gained a file-browser sidebar. The shell pane, which used to be a full-viewport restty surface, is now a two-column flex: a 280 px sidebar on the left, restty on the right. Browser and VS Code tabs are unchanged — the sidebar only mounts on kind: "shell".

The sidebar is driven by the same /files endpoints the E2B SDK uses (GET /files?path=…, GET /files/list?path=…, POST /files/make-dir, POST /files/rename, DELETE /files?path=…, plus raw-body POST /files for writes). No new gateway routes — the UI calls them same-origin and the gateway’s existing sandbox_from_host plumbing steers requests to the right jail.

Interactions:

Per-tab state (the set of expanded folders, the selected path, the collapsed-sidebar flag) lives on the TabRec — switching tabs preserves scroll and expansion. The preview modal itself is a singleton attached to document.body; Escape or click-outside closes it.

The sidebar picked Option A from the three layout options the task spec floated (modal preview vs. bottom split vs. new tab kind). Bottom-split fights restty’s own split manager: restty treats its root as the whole surface and would start placing horizontal splits inside the preview region. A new tab kind is more work than the read-a-file case wants.

Inner views: VNC and RDP (#82)

Beyond chromium and code-server, the portal also surfaces two more inner views: VNC (Xvnc + noVNC canvas) and RDP (xrdp + IronRDP-web WASM). Both appear in the inner + menu alongside chromium and code-server, and follow the same “views, not kinds” model — any jail-backed sandbox can host either one if its template carries the server-side binaries.

The launch shape is identical to chromium’s:

  1. Precheckwhich Xvnc (or which xrdp) inside the jail. If the binary isn’t present, surface a clear “not installed in this template” error instead of leaving the user on a spinner. The supported path is the dedicated desktop template; other templates should fail fast at precheck instead of half-working.
  2. Launch via daemon -f -o /var/log/… so the detached server process survives the POST /sandboxes/:id/exec request returning. openbox-session + xterm + xclock ride along so the rendered surface isn’t a blank grey Xvnc canvas; if firefox is in the template it also opens on about:blank so the first paint looks like a desktop, not a rescue shell.
  3. Port readiness pollsockstat -l -p 5900 (or 3389) inside the jail every 500 ms, 30 s budget.
  4. Gateway WS bridge — the operator’s browser opens ws://gateway/vnc-proxy/<id>/websockify (or /rdp-proxy/<id>/). VNC is a straight WS binary frame ↔ TCP bridge into 10.78.0.<M>:5900. RDP is not: IronRDP-web starts with an RDCleanPath handshake, so the gateway terminates that handshake itself, opens TLS to 10.78.0.<M>:3389, returns the xrdp certificate chain, and only then proxies post-handshake RDP traffic. There’s no URL rewriting — the wire formats stay binary end-to-end.

The operator-side client code is lazy-loaded. noVNC still comes from esm.sh, but IronRDP now ships from vendored, same-origin assets under e2b-compat/ui-src/public/vendor/ironrdp/. The npm-published IronRDP packages were too old for xrdp’s slow-path update PDUs, and the GitHub-on-esm.sh fallback could not expose the generated wasm package consistently on the tunneled /ui/ path. Keeping the RDP modules same-origin avoids that failure mode entirely.

:::warning[Demo-only RDP credentials] The RDP view authenticates as root with an empty password and expects the template’s xrdp/sesman.ini to accept that (either with PAM disabled or a stub user database). This is a demo-only posture. The VNC view uses -SecurityTypes None with no auth at all. Do not point either view at a sandbox that isn’t fresh, ephemeral, and network-isolated. The desktop-templates.md follow-up spec captures the exact config for the preview image. :::

Gateway plumbing lives in e2b-compat/src/sandbox_proxy.rs (new vnc_proxy, rdp_proxy, and shared bridge helpers); routes in e2b-compat/src/routes.rs; UI in e2b-compat/ui-src/src/views/VncView.tsx and RdpView.tsx; inner + menu in components/TabStripInner.tsx.

Outer kind: Windows host console

The portal’s windows menu item is a different shape from the jail-backed desktop views. It creates templateID:“windows-server-2025”, which is a bhyve template imported from Microsoft’s Server 2025 evaluation VHDX rather than a jail snapshot.

The window it opens is intentionally narrower in scope:

The console itself is still the regular VNC view component, but in a host-console mode. The bhyve backend returns metadata with coppice_console_vnc_host and coppice_console_vnc_port; the UI notices that and skips the normal in-jail Xvnc launch. The browser still dials the same-origin gateway route /vnc-proxy/<id>/websockify, but the proxy’s TCP target is the host-side bhyve framebuffer socket on 127.0.0.1:<port>, not 10.78.0.<M>:5900 inside a jail.

That distinction matters during first boot. Windows reboots during OOBE; bhyve exits 0 in that case (“guest rebooted”), the host-console launcher restarts it on the same VNC port, and the noVNC surface reconnects. A transient browser-console 1006 disconnect can still show up at that reboot boundary, but the visible session now returns and lands on OOBE or the lock screen instead of dying permanently.

Operator controls are the same as the jail-backed VNC view:

Browsers cannot pass a literal Ctrl + Alt + Del chord through to the page, so those are the supported equivalents. The Windows-specific backend and staging story lives in /appendix/windows-bhyve-template.

Roadmap past v1

Files