0048: A terminal in the web head¶
Context and Problem Statement¶
The web head (ADR 0039) shows what hz knows and lets you chat with the agent runner, but much of the work around
it still happens in a terminal: signing a vendor CLI in (claude then /login, gws auth login), running a setup
step, trying a command the app suggests. Switching to another window for that breaks the flow the app is meant to
keep, and desktop coding agents put a terminal next to their chat for the same reason. Should the web head offer one?
Decision Drivers¶
- The terminal is a real shell on the user's own machine, so it must be no easier to reach than the user's account:
only the app
hz uiopened, never another web page or another user. - Nothing new to install or run; it works on macOS and Linux, where
hz uiruns. - It looks and behaves like a terminal: colours, full-screen programs, resizing, copy and paste.
Considered Options¶
- A pseudo-terminal behind a WebSocket on the web head, rendered with xterm.js (chosen)
- No terminal; copy the command and switch windows: what the app does today; the friction this record removes.
- Run individual
hzcommands from the app: covers onlyhzitself, not vendor CLIs' interactive sign-in, and would need a second, weaker sandbox story for each command.
Decision Outcome¶
Chosen option: a pseudo-terminal behind a WebSocket, because it is the user's own shell, gated by the same checks as the rest of the web head, with no new process to manage.
/api/terminalis a WebSocket on the web head. Before it is accepted, the handshake must pass the API's checks: a loopback Host on our port, our own Origin (a browser always sends one with a WebSocket, so none is refused too), no cross-siteSec-Fetch-Site. A browser can't set headers on a WebSocket, so the session token arrives as the first message within five seconds and is compared in constant time.- Each connection starts the user's login shell (
$SHELLwhen absolute and executable, elsesh) with-lin their home directory, in a session of its own whose controlling terminal is a new pseudo-terminal. Binary frames carry keystrokes and output; text frames carry{"resize": [cols, rows]}from the browser and{"agent_title": ...}from the server when the foreground agent supplies a session name. At most eight terminals run at once; one more is closed with code 1013 (try again later). - For Claude Code, the server reads only the foreground process's local session metadata, checks its process identity and start time, and sends the name to that terminal's socket. A missing or unrecognised record leaves the terminal's own title in place. Other programs can set their terminal title and report status through standard terminal sequences.
- The forked child runs no Python, because
preexec_fnis unsafe once the server has threads:start_new_sessionmakes it a session leader, and it execs a freshpython -I -Sthat takes the pseudo-terminal as its controlling terminal (TIOCSCTTY, which macOS and Linux both need) and then execs the shell. The cost is one interpreter start per terminal, tens of milliseconds. - The shell's environment is the user's, as a desktop terminal gives it: hz's virtual environment is taken out
(
VIRTUAL_ENV,UV_*, and hz'sbindirectory onPATH, sopythonandpipare the user's),NO_COLORis dropped, andTERM=xterm-256color,COLORTERM=truecolor,CLICOLOR=1andTERM_PROGRAM=heinzelare set. - Both directions are bounded. The shell's output waits in a queue of 64 reads; when it is full, the server stops
reading the pseudo-terminal, so a shell printing faster than the browser reads (
yes) blocks instead of growing memory. Keystrokes the pseudo-terminal can't take yet wait in a buffer of up to 1 MiB; beyond that the server stops reading the socket until the shell has caught up. - Closing the socket, the panel or the app sends SIGHUP to the shell's process group, closes the pseudo-terminal,
then sends SIGKILL to the group after two seconds; a server shutting down kills it at once. Jobs the shell runs
in their own process groups get SIGHUP as when a terminal window closes: the kernel hangs up the terminal's
foreground job, and shells like bash and zsh pass SIGHUP on to their other jobs. A job started with
nohupor disowned survives, as it does there. - The app renders it with xterm.js in a panel under the chat, opened from the header's terminal button. On Windows the socket closes with a reason the panel shows.
- uvicorn needs a WebSocket implementation;
wsprotois the smallest (pure Python, onh11, which uvicorn uses). - The Content Security Policy allows inline styles (
style-src 'self' 'unsafe-inline'), because xterm.js writes its measurements into<style>elements. Scripts stay this origin's only, so injected markup still can't run code.
Consequences¶
- Good: sign-ins, setup steps and suggested commands happen next to the app; the shell is the user's own, with their configuration and tools.
- Bad: the session token now opens a shell, not just
hzoperations. It already had to stay secret (it is kept insessionStorage, sent only as a header or this first message, and dies withhz ui); a leak is now worse, which is why the handshake checks run before the socket is accepted. - Bad: inline styles are allowed. Style injection can restyle the page but not run code or send data elsewhere
(
connect-src,img-srcandfont-srcstay this origin's).
Confirmation¶
Integration tests open the socket through the web head: a wrong Origin or a missing Origin is refused before
accepting, a wrong token closes it, the right token runs a command with a controlling terminal and returns its
output, and a resize reaches the terminal (stty size). yes against a browser that doesn't read leaves the output
queue bounded and reading paused, and the session recovers once the browser reads; 100 KiB of keystrokes all reach
wc -c; the shell is gone once the socket closes; a terminal beyond the cap is closed with 1013; and the shell's
environment has hz's virtual environment taken out and a desktop terminal's colour variables set.