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:
- A tab strip at the top. A
+button opens a menu; pick shell to POST/sandboxeswithtemplateID: "python"and open a terminal tab, or browser to POSTtemplateID: "browser"and open a tab that renders the Chrome DevTools inspector attached to a headless chromium inside the jail. Shell and browser tabs coexist — the tab strip gets a small>_or[B]glyph per kind. - A full-viewport restty
terminal attached to the active tab via a WebSocket. Restty is
libghostty-vt compiled to WASM with a
WebGPU renderer — the same parser and glyph atlas the native
Ghostty terminal uses, in the browser. Keystrokes are serialised
as
{"type":"input","data":"…"}text frames; pty output comes back as binary frames; resize events go out as{"type":"resize","cols":N,"rows":M}. The gateway spawnsjexec -U root e2b-<id> /bin/shinside a real pty (portable-pty), so the shell behaves like a shell: line editing, job control,Ctrl-C,less,vi. - Split panes inside a tab. Restty ships a built-in split
manager — press Cmd + D on macOS (or
Ctrl + Shift + D elsewhere) for a
vertical split, Cmd + Shift + D /
Ctrl + Shift + E for horizontal.
Each new pane opens a fresh pty into the same jail — both prompts
report the same
hostname. The portal intentionally re-wires the shortcut listener so bare Ctrl + D still reaches the pty as EOF; the library’s default (unshifted Ctrl/Cmd+D) would swallow it. - A status bar at the bottom. The SPA polls
GET /sandboxes/:idevery 2 s for the active tab and renderscpu%, memory, uptime, the sandbox IP, and thetemplateID. Two buttons on the right:air-gaptoggles the sandbox’s pf anchor viaPUT /sandboxes/:id/network;killissuesDELETE /sandboxes/:idand closes the tab. A 404 from the poll loop (TTL reaper got there first) also closes the tab.
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:
- Launch chromium.
POST /sandboxes/:id/execwith a smallsh -cthat brings uplo0, starts/usr/local/bin/chrome --headless=new --remote-debugging-port=9222, and backgrounds asocat TCP4-LISTEN:9222 … TCP6:[::1]:9222trampoline. The socat step is load-bearing on FreeBSD: chromium 147 ignores--remote-debugging-addressand pins CDP to[::1], so external reach from the bridge requires the ipv4↔ipv6 hop. - Wait for CDP. Poll
GET /cdp-proxy/:id/json/versionevery 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”. - Open devtools.
GET /cdp-proxy/:id/jsonreturns the list of targets; the UI picks the firsttype:"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:
404— no such sandbox inAppState.502— sandbox exists but has no allocated IP, or the CDP port isn’t answering. The UI’s readiness poll distinguishes these from200with a hundred milliseconds of latency.
v1 limits
- Ephemeral session. Reload = lose your tabs. The sandboxes
themselves live until TTL or a manual
kill— they don’t get reattached to the UI. A future version should list running sandboxes on open and offer to attach. - No confirmation on kill. Click-through is fine for a bench demo; a confirmation dialog is cheap to add when the portal leaves localhost.
- No auth, no CORS. Same-origin only; the gateway serves both
/ui/and the API on:3000. - One inspector per browser tab. No multiplexing on the browser side. Shell tabs lift this via restty’s split panes (N ptys in one tab, all against the same jail); a second browser tab covers the “two CDP inspectors” case.
- Devtools-only browser surface. The iframe shows the DevTools
inspector, not the rendered page itself. A screencast viewer
(option B from the #71 v1 spec —
Page.startScreencast+<canvas>) is the obvious next step when an operator wants to see what chromium is rendering rather than just drive it.
Roadmap past v1
- Sandbox picker at open.
GET /sandboxeson boot, offer a list the user can reattach to. - Template picker in the + menu. Driven by
GET /templates, which is already live (#68). - Confirm-on-kill. Small in-UI modal; probably just swap the button label to “sure?” for two seconds.
- Small-screen layout pass. The baseline viewport for the site’s polish is 390×844; the portal hasn’t had the same audit.
- Rendered-page view for browser tabs.
Page.startScreencastinto a<canvas>, plusInput.dispatchMouseEvent/Input.dispatchKeyEventso the operator can drive the page directly rather than through the DevTools console.
Files
e2b-compat/src/ws.rs— pty bridge (shell tabs).e2b-compat/src/cdp_proxy.rs— HTTP + WS proxy for the in-jail chromium’s CDP port, with JSON-body URL rewriting. Nine unit tests cover rewrite edge cases and Upgrade-header sniffing.e2b-compat/src/routes.rs—/uiand nested/ui/static route, plus/cdp-proxy/:id/*pathwired intobuild_router.e2b-compat/src/main.rs—--ui-dirflag,cdp_proxymodule registration.e2b-compat/ui/index.html+app.js+style.css— the SPA. Tab rendering, shell/browser dispatch, the chromium launch + CDP readiness poll, and the devtools iframe.e2b-compat/tests/ui.rs— seven-case assertion on the static surface (HTML content, redirect, ws path in app.js, browser-option enablement, CDP-proxy URL needle, restty pinned import).benchmarks/rigs/ui-smoke.sh— curl-and-grep receipt.