feat(web,host): Terminal tab in the web app
PR 2 of 2 for the Terminal tab in the web app (plan "📱 Web support", "What a phone can't have yet"). PR 1, #4357, taught the relay to admit a page of the deployment's own origin by its session cookie. This PR runs the host's terminal concern in the tab, so an agent's Terminal tab works on the web.
What changes
@luke/host: relay sockets open under the account's call credential (relay-credential.ts, wasrelay-bearer.ts), not its token.- On the desktop,
accountBearer(token)gives the same handshake as before:Authorization: Bearer …, renewed once on a 401 and re-sent only if it changed. - The tab's cookie session credential has no
authorizationmember, so the socket opens with no header of the host's own. The browser carries the cookie and the page'sOriginitself. - A signed-out cookie credential opens nothing and is refused 401 before anything travels.
- On the desktop,
- The Terminal tab's act procedures move into
@luke/host/act-port(terminal-procedures.ts). They come from the desktop'sipc/terminal-acts.ts, git-detected as a rename.- They are generic over the platform's window handle, and
forkreplaces the desktop's promiserun. - The desktop keeps only its port seam (
MessageChannelMainand the preload) andpage-gone.ts. - Root
AGENTS.md's Layout rule asks for this: the module named no Electron API beyond aWebContentstype.
- They are generic over the platform's window handle, and
apps/web:web-terminal-socket.tsis the browser'sOpenTerminalSocket: a plain same-originnew WebSocket(url), binary typearraybuffer, no subprotocol and no header.composeWebchoosesHOST_PLATFORM_CONCERN.TERMINALSand provides that socket. It answers the Terminal tab's acts by posting each shell's port to its own window underTERMINAL_PORT_CHANNEL, as the desktop's preload does. The panel'sonTerminalPortalready takes a port only fromevent.source === window.- The host now offers
HOST_CAPABILITY.TERMINALSon the web, so the panel draws the Terminal tab and the "Show terminal" command.
Behaviour notes
- A refused upgrade can't be told from a dropped one. A browser hides why an upgrade was refused (401, 403, 404, 503), so the host reads a refusal as a socket that never opened. It then waits and retries on the composer's backoff (doubling to 10 s) until the tab lets go or the account signs out; a sign-out resets every connection. The relay's own outcomes still reach the tab, because they are close codes after the upgrade: gone, stopped, starting, idle, exited.
- An open that came early still counts. A socket that opened or closed before the seam listened is read from its
readyState, so an attach never waits on an event that already came. - No real pause. A page's socket cannot stop reading the network, so the seam's
pauseholds what arrived in the page untilresume. The tab's ack-based pacing of what xterm draws still holds. What the page holds is bounded at 4 MiB, forty times the screen's pause watermark. Past that, the page lets the socket go and the host's reconnect redraws the shell, which tmux kept, so a tab that stopped drawing can't grow the page without bound. - No CSP change. The relay is same-origin, and
connect-src 'self'covers same-originwss:. - No wake on return. A wake re-opens every standing socket, even a healthy one. On the web, a tab's visibility flips often, so a dropped socket reconnects through the backoff instead (500 ms after an attached shell drops).
Privacy
The shell's bytes go relay → the page's socket → the host in the tab → a MessagePort → the panel in the same tab. They are never written to app state, a trace, a counted event, the console or storage, as root AGENTS.md's Terminal tab line requires. Session replay already leaves the whole Terminal tab out (ph-no-capture). Nothing new is stored, so PRIVACY.md doesn't change.
Tests
- Browser socket:
apps/web/tests/web-terminal-socket.test.ts, with a stand-in WebSocket asweb-browser-live.test.tsuses. It covers:- the socket opens at the host's address with no subprotocol, and a bearer handed to the seam never rides;
- binary frames arrive as bytes, text as text, and the close last;
- a socket that closes before it opens reads as unreachable;
- sends go out as bytes or text, nothing after the close, and the scope's close closes the socket, opened or not;
- a pause holds arrivals in order until the resume.
- The whole web host:
apps/web/tests/web-host.test.tscomposes it as the tab does, then:- checks that the host offers terminals;
- checks that
terminal.attachopens the relay atws://<origin>/api/terminal/sessions?agent=…and posts the shell's port to the page at its own origin; - drives the relay's
attachedframe and output through to the port as state and output messages; - checks that typing on the port reaches the socket.
- Host credential:
packages/host/src/compose-terminals.test.tsadds a case where a cookie session opens the relay with no header and a signed-out one opens nothing. The bearer and renewal cases are unchanged, now fedaccountBearer(token). - Moved tests: the procedures' suite moved with them (
act-port/terminal-procedures.test.ts), andpage-gone.test.tsstays in the desktop.
Phone check at 390px
I drew the real Terminal tab (real panel CSS and xterm) full screen at 390×844 at 3× scale, as a phone's side panel draws it, over a scripted shell. Then I typed ls and shrank the stage to 390×508, as the visual-viewport handling from #4171 does when the keyboard comes up.
- It fits: the shells list floats over the screen below 480px (existing container query), and the prompt stays at the bottom, above the keyboard.
- A web-only bug turned up: the terminal measured its cells before the JetBrains Mono web font landed, so the first resize (the keyboard) drew about 17% past the right edge. #4361 fixes that on its own (
fix(panel)), and this PR doesn't depend on it. - Not checked: headless Chromium's software WebGL draws xterm's glyphs at the device scale twice, on
maintoo, so those shots used the DOM renderer. WebGL on a real phone is for the developer to check by hand.
Made with Luke