feat(service): admit a page's terminal relay upgrade by its session cookie
PR 1 of 2 for the Terminal tab in the web app (plan "📱 Web support", "What a phone can't have yet"). This is the service half; the browser socket and composeWeb wiring follow in PR 2.
Why
The agent terminal relay (/api/terminal/sessions) refused any WebSocket upgrade that named an Origin, and it required an Authorization header. A browser always names an Origin and can't set Authorization on a WebSocket, so the web app had no way in.
Design: follow #4314, no ticket
The brief asked for one-time ticket admission. While reading the relay, I found that #4314 had merged the day before. It already admits a browser on the sibling socket in the same function edge (/api/browser/live), by its session cookie, from the deployment's own origins only. The web's Browser tab opens that socket as a plain same-origin WebSocket. The terminal relay is same-origin too, so the cookie rides the upgrade. The orchestrator agreed to follow that precedent. So there is no ticket table, no mint procedure, no single-use clock and no secret in a URL. Nothing new is stored, so PRIVACY.md doesn't change.
Both relays now admit through one shared helper, http/relay-upgrade.ts. The rules, read from the headers alone, before any socket stands:
| Upgrade | Result |
|---|---|
No Origin, Authorization: Bearer … | The desktop, exactly as before |
No Origin and no bearer, with or without a cookie | 401 |
Origin on authDeployment(...).ownOrigins, with a cookie | A page, read by its cookie alone. Any Authorization beside it is dropped. |
Origin on ownOrigins, no cookie | 401 |
Any other Origin, including null, http: for https:, and look-alike suffixes | 403, before any credential is read |
Then one shared resolver, bearer.ts's relayUserId, names the account:
- The bearer goes through userinfo, as on every hosted route.
- The cookie goes through Better Auth's session read, as every route reads a browser's (
requestUserId). - Nobody: if neither names an account, the upgrade is refused 401.
The relay then reads the agent under that account by the one reach rule every agent route keeps (readCodingAgent + controlsAgent: a workspace the account is in, not archived, has a repository, and for an agent on its starter's own GitHub token, the starter alone). Anything else closes as gone.
ownOrigins is the origin guard's list from #3365, never Better Auth's trustedOrigins. On production, trustedOrigins trusts every preview and the dev server for the OAuth proxy.
Threat model
1. A cross-site page opening the socket (cross-site WebSocket hijacking). A WebSocket handshake is a GET that the browser sends with the target site's cookies and no CORS preflight, so a cookie-admitted socket is hijackable unless the origin is checked.
- The browser always names the opening page's
Originon the handshake, and page script can't set or remove it (it's a forbidden header). - The relay takes a page only when that
Originexactly equals one of the deployment's own origins. Any other is refused 403 before the session store is asked anything. - So a page on another site, carrying the member's cookie, never gets a socket.
- A preview owns only its own hostnames, so a preview's page can't open production's relay, and the reverse is also true.
- The session cookie's SameSite is defense in depth, not the control.
2. Credential theft from logs, Sentry or the referrer. There is no new credential to steal:
- The upgrade carries the HttpOnly session cookie in the
Cookieheader, as every same-origin fetch from the app already does. - The URL carries only the agent id, the shell id and the size, as the desktop's always has.
- Nothing is minted, stored or put in a query string, so nothing can be replayed or needs scrubbing.
- The relay's Sentry setup attaches no request headers: there's no
sendDefaultPii, and the Effect SDK's server init starts no HTTP integration. - Session expiry and revocation are the cookie's own. They are read fresh at every upgrade, including each reconnect.
3. Another account's terminal.
- Only the page's cookie names the account. The URL has no user id, and an
Authorizationbeside a page'sOriginis never read. - The agent is read under that account by the same rule the desktop path and every agent route use. An agent outside it is closed as
gonebefore anything in its sandbox is touched. - The shell id only picks a tmux session inside a sandbox the account already reaches.
- So a page reaches exactly the shells its account could open from the desktop.
4. A downgrade of the desktop path.
- An upgrade with no
Originis still taken only under a non-blank bearer, resolved exactly as before. Every desktop status is unchanged, and the tests pin them. - A cookie without an
Originis refused 401, so the cookie path can't be used without naming an own origin. - The page path never carries a bearer, and the bearer path never reads a cookie.
Out of scope:
- A non-browser client that forges an own
Originstill needs a valid session cookie. That is the same as being signed in on the web, and every cookie route already accepts it. TheOrigincheck protects browsers; it isn't authentication. - Script injected into our own origin (XSS) could already act as the member on every cookie route. The app's CSP (
script-src 'self') is the control there.
Tests
The brief's test list, adapted to cookie admission:
- Wrong origin:
terminal-relay-upgrade.test.tschecks that another site,null,http:and a suffix trick are refused 403 whatever the request carries.terminal/service.test.tschecks the same over a real loopback socket, and that no credential is read and no agent is reached. - No origin with a cookie (the ticket version was "no origin with a ticket"): refused 401, both at admission and over the loopback socket.
- Another account's terminal:
terminal/service.test.ts. A page of the deployment's own origin whose cookie names Grace, from another workspace, asks for Ada's agent. Reach is asked under Grace's account and the socket closes asgone. Ada's own cookie reaches the agent (starting). - Desktop bearer path unchanged: the same statuses at admission (bearer taken; missing or blank bearer 401; bad path 404; unreadable ask 400). Over the loopback socket, the bearer is the only credential read, and it reaches the agent as that account.
- Replayed or expired ticket: not applicable. There's no ticket, and the session's own expiry and revocation apply at each upgrade (
bearer.test.tscovers the session reads). - Shared resolver:
bearer.test.tschecks thatrelayUserIdnames the bearer's account and the cookie's account over real Better Auth sessions on PGlite, and nobody for a cookie signed under another secret. - The Browser tab's live view keeps its own suite unchanged (
browser-live/service.test.ts), now running through the shared helper.
Not here
- PR 2: the browser
TerminalSocket,composeWebwiring, the Terminal tab's act procedures shared into@luke/host, and a check at 390px wide. - No CSP change: the relay is same-origin, and
connect-src 'self'covers same-originwss:under CSP Level 3. WebKit fixed its old mismatch in 2022 (bug 235873).
Made with Luke