feat(web,host): Terminal tab in the web app

DA
4d ago
+863-221

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, was relay-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 authorization member, so the socket opens with no header of the host's own. The browser carries the cookie and the page's Origin itself.
    • A signed-out cookie credential opens nothing and is refused 401 before anything travels.
  • The Terminal tab's act procedures move into @luke/host/act-port (terminal-procedures.ts). They come from the desktop's ipc/terminal-acts.ts, git-detected as a rename.
    • They are generic over the platform's window handle, and fork replaces the desktop's promise run.
    • The desktop keeps only its port seam (MessageChannelMain and the preload) and page-gone.ts.
    • Root AGENTS.md's Layout rule asks for this: the module named no Electron API beyond a WebContents type.
  • apps/web:
    • web-terminal-socket.ts is the browser's OpenTerminalSocket: a plain same-origin new WebSocket(url), binary type arraybuffer, no subprotocol and no header.
    • composeWeb chooses HOST_PLATFORM_CONCERN.TERMINALS and provides that socket. It answers the Terminal tab's acts by posting each shell's port to its own window under TERMINAL_PORT_CHANNEL, as the desktop's preload does. The panel's onTerminalPort already takes a port only from event.source === window.
    • The host now offers HOST_CAPABILITY.TERMINALS on 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 pause holds what arrived in the page until resume. 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-origin wss:.
  • 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 as web-browser-live.test.ts uses. 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.ts composes it as the tab does, then:
    • checks that the host offers terminals;
    • checks that terminal.attach opens the relay at ws://<origin>/api/terminal/sessions?agent=… and posts the shell's port to the page at its own origin;
    • drives the relay's attached frame 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.ts adds 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 fed accountBearer(token).
  • Moved tests: the procedures' suite moved with them (act-port/terminal-procedures.test.ts), and page-gone.test.ts stays 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 main too, so those shots used the DOM renderer. WebGL on a real phone is for the developer to check by hand.

Made with Luke


Devin Review