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:
- A tab strip at the top. A + button opens a menu of outer
sandbox kinds: shell, browser, vscode, desktop, and
windows. Each picks a default
templateID, opens one outer tab, and then mounts one or more inner views inside it. The tab strip carries a small glyph per kind:>_,[B],[V],[D], or[W]. - 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.
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:
- Tree. Root is
/root. Folders sort above files, alphabetical within each. Click a folder → toggle expand, lazy-load children on first open via/files/list. Click a file → preview modal. Double-click also opens the preview. - Preview. Small text files render as a mono
<pre>; images (detected by MIME or extension) render as<img>; anything with embedded NUL bytes or a run of non-text bytes shows<binary, N bytes>. Files over 256 KiB get a “too big to preview” message with a download button. - New file / new folder. Two
+buttons in the sidebar header. Prompt for a name, POST the right endpoint, refresh/root. - Drag-drop. Drop OS files onto the sidebar →
POST /fileswith the file’s raw bytes as the body. Uploads over 10 KiB show a progress bar along the top of the sidebar (XHR upload progress; fetch doesn’t expose it cleanly). - Delete. Select a row → the
×button in the header confirms viawindow.confirmand issuesDELETE /files?path=…. - Rename. Click the name of an already-selected row → inline
<input>on the row. Enter commits viaPOST /files/rename, Escape cancels. - Refresh. The
↻button re-lists every currently-expanded directory. There is no auto-polling — the terminal can write files the sidebar doesn’t know about, and polling every second is wasteful. The refresh button is the escape hatch. The backendfiles.watchDir()surface is live now, but the sidebar still doesn’t subscribe to it, so explicit refresh remains the current UI behaviour. - Keyboard. Cmd + B (macOS) / Ctrl + B (elsewhere) toggles the sidebar. Intentionally distinct from the split shortcut (Cmd + D / Ctrl + Shift + D). Bare Ctrl + B in a non-mac context is readline’s backward-char — the UI-level binding only fires when the shell tab is active, so the pty still receives Ctrl + B for readline cursor movement when you’re inside the terminal focus.
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:
- Precheck —
which Xvnc(orwhich 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 dedicateddesktoptemplate; other templates should fail fast at precheck instead of half-working. - Launch via
daemon -f -o /var/log/…so the detached server process survives thePOST /sandboxes/:id/execrequest returning.openbox-session+xterm+xclockride along so the rendered surface isn’t a blank grey Xvnc canvas; iffirefoxis in the template it also opens onabout:blankso the first paint looks like a desktop, not a rescue shell. - Port readiness poll —
sockstat -l -p 5900(or 3389) inside the jail every 500 ms, 30 s budget. - 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 into10.78.0.<M>:5900. RDP is not: IronRDP-web starts with an RDCleanPath handshake, so the gateway terminates that handshake itself, opens TLS to10.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:
- One outer tab with glyph
[W]. - One inner tab named console.
- No shell, files, chromium, code-server, wasm tools, or RDP entries in the inner + menu until in-guest bootstrap exists.
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:
- the toolbar
ctrl-alt-delbutton, or - Ctrl + Alt + End.
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
- 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, the devtools iframe, and the shell-tab file-browser sidebar + preview modal.e2b-compat/tests/ui.rs— static-surface assertions: HTML content,/ui/redirect, ws path in app.js, browser-option enablement, CDP-proxy URL needle, restty pinned import, and thefile-browserneedle that guards against accidental sidebar removal.benchmarks/rigs/ui-smoke.sh— curl-and-grep receipt.