From 25ef24e92ef1b9dbe121704e899c838d48b86001 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 23 May 2026 23:17:42 -0700 Subject: [PATCH] feat(server): /pty-session 4-tuple + /pty-restart + /pty-dispose + lease-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the lease + attachToken model end-to-end on the server side. The client side (extension) lands in the next commit; agent side already shipped in 449144cd. Routes: * POST /pty-session — mints sessionId (stable, loggable) + lease (server-side bookkeeping) + attachToken (short-lived bearer for the WS upgrade). Returns the 4-tuple in one round trip. Legacy ptySessionToken / expiresAt aliases kept for one minor release so extensions on the v1.43 wire shape keep working. * POST /pty-session/reattach — validates a sessionId's lease and mints a FRESH attachToken bound to the same sessionId. Used by Commit 3's re-attach loop; 410 Gone when the lease has expired so the client knows to fall back to a brand-new /pty-session. * POST /pty-restart — one transaction: dispose the caller's existing PtySession on the agent (via /internal/restart, scoped to one sessionId — codex T2), revoke the old lease, mint a fresh sessionId + lease + attachToken, return the 4-tuple. Zero race window between kill and mint (codex T2 + D8 of the eng review). * POST /pty-dispose — explicit teardown. sendBeacon-compatible: accepts auth token in the body so the extension's pagehide handler (Commit 2C) can fire it without setting custom headers (sendBeacon doesn't support those). Without this route, every clean browser quit leaves a zombie PTY alive for the 60s detach window — codex T3 caught it. * POST /internal/lease-refresh — loopback from terminal-agent on its 25s keepalive cycle (lazy: only when lease is within 5 min of expiry). Refreshes the lease AND resets the daemon idle timer. T6 of the eng review: PTY activity (not arbitrary SSE consumers) is what keeps the daemon alive when the sidebar is in use. Helpers: * grantPtyToken now accepts optional sessionId and passes it through to the agent's /internal/grant body. The agent binds token → sessionId in its validTokens Map so /ws upgrades carry the sessionId for /internal/restart and Commit 3 re-attach lookups. * restartPtySession() — new loopback helper that POSTs the agent's scoped /internal/restart with a sessionId body. Used by /pty-restart and /pty-dispose. Auth contract on /pty-dispose deliberately accepts the auth token in EITHER the Authorization header OR the request body. The body path is required for sendBeacon (which can't set custom headers); the header path stays available for non-beacon callers and tests. Test (browse/test/server-pty-lease-routes.test.ts): * 7 static-grep tripwires pinning the 4-tuple shape, validate-first re-attach with 410 fallback, one-transaction restart semantics, sendBeacon-compatible dispose auth, and the T6 PTY-only idle reset. * Live route exercises (full mint + grant + WS upgrade cycle) belong in the e2e tier — they require a real terminal-agent loopback and take seconds per assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/server.ts | 248 +++++++++++++++++--- browse/test/server-pty-lease-routes.test.ts | 94 ++++++++ 2 files changed, 314 insertions(+), 28 deletions(-) create mode 100644 browse/test/server-pty-lease-routes.test.ts diff --git a/browse/src/server.ts b/browse/src/server.ts index 9511f67b1..0bd020d53 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -58,6 +58,9 @@ import { import { mintPtySessionToken, buildPtySetCookie, revokePtySessionToken, } from './pty-session-cookie'; +import { + mintLease, validateLease, refreshLease, revokeLease, +} from './pty-session-lease'; import * as fs from 'fs'; import * as net from 'net'; import * as path from 'path'; @@ -409,11 +412,13 @@ function readTerminalInternalToken(): string | null { /** * Push a freshly-minted PTY cookie token to the terminal-agent so its - * /ws upgrade can validate the cookie. Loopback POST authenticated with - * the internal token written by the agent at startup. Fire-and-forget; - * if the agent isn't up yet, the extension just retries /pty-session. + * /ws upgrade can validate the cookie. v1.44+: also pushes the bound + * sessionId so the agent can route /internal/restart and (Commit 3) + * re-attach back to the same PtySession. Loopback POST authenticated + * with the internal token written by the agent at startup. If the agent + * isn't up yet, the extension just retries /pty-session. */ -async function grantPtyToken(token: string): Promise { +async function grantPtyToken(token: string, sessionId?: string): Promise { const port = readTerminalPort(); const internal = readTerminalInternalToken(); if (!port || !internal) return false; @@ -424,13 +429,36 @@ async function grantPtyToken(token: string): Promise { 'Content-Type': 'application/json', 'Authorization': `Bearer ${internal}`, }, - body: JSON.stringify({ token }), + body: JSON.stringify(sessionId ? { token, sessionId } : { token }), signal: AbortSignal.timeout(2000), }); return resp.ok; } catch { return false; } } +/** + * Ask the terminal-agent to dispose the PtySession bound to `sessionId`. + * Scoped to one caller's session — sibling tabs/agents untouched. Used by + * /pty-restart and /pty-dispose. Returns true on agent ack. + */ +async function restartPtySession(sessionId: string): Promise { + const port = readTerminalPort(); + const internal = readTerminalInternalToken(); + if (!port || !internal) return false; + try { + const resp = await fetch(`http://127.0.0.1:${port}/internal/restart`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${internal}`, + }, + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(5000), + }); + return resp.ok; + } catch { return false; } +} + /** Extract bearer token from request. Returns the token string or null. */ function extractToken(req: Request): string | null { const header = req.headers.get('authorization'); @@ -1598,15 +1626,25 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { }); } - // ─── /pty-session — mint Terminal-tab WebSocket cookie ─────────── + // ─── /pty-session — mint sessionId + lease + attachToken ───────── // - // The extension POSTs here with the bootstrap authToken, gets back a - // short-lived HttpOnly cookie scoped to the terminal-agent's /ws - // upgrade. We push the cookie value to the agent over loopback so the - // upgrade can validate it. The cookie travels automatically with the - // browser's WebSocket upgrade because it's same-origin to the agent - // when the daemon binds 127.0.0.1. NEVER added to TUNNEL_PATHS — the - // tunnel surface 404s any /pty-session attempt by default-deny. + // v1.44+ four-tuple shape: + // { terminalPort, sessionId, attachToken, leaseExpiresAt } + // + // - sessionId : stable, non-secret. Safe to log. Identifies "this + // terminal" across re-attaches. + // - attachToken : short-lived (30 min wall, single attach in practice + // since the agent revokes on WS close). Bearer for + // the /ws upgrade. + // - leaseExpiresAt: client-visible deadline for the lease. Re-attach + // only works inside this window. + // + // The lease + attachToken are minted together so a successful + // /pty-session is one round trip. Re-attach mints a fresh attachToken + // for the SAME sessionId via /pty-session/reattach. + // + // NEVER added to TUNNEL_PATHS — the tunnel surface 404s any + // /pty-session attempt by default-deny. if (url.pathname === '/pty-session' && req.method === 'POST') { if (!validateAuth(req)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { @@ -1619,41 +1657,195 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { error: 'terminal-agent not ready', }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } + const lease = mintLease(); const minted = mintPtySessionToken(); - const granted = await grantPtyToken(minted.token); + const granted = await grantPtyToken(minted.token, lease.sessionId); if (!granted) { revokePtySessionToken(minted.token); + revokeLease(lease.sessionId); return new Response(JSON.stringify({ error: 'failed to grant terminal session', }), { status: 503, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ terminalPort: port, - // Returned in the JSON body so the extension can pass it to - // `new WebSocket(url, [token])`. Browsers translate that to a - // `Sec-WebSocket-Protocol` header — the only auth header we can - // set from the browser WebSocket API. SameSite=Strict cookies - // don't survive the port change between server.ts (34567) and - // the agent (random port), and HttpOnly + cross-origin makes - // the cookie path unreliable across browsers anyway. - // - // The token is short-lived (30 min, auto-revoked on WS close) - // and never persisted to disk on the extension side. The - // pre-existing authToken leak via /health is a separate - // concern (v1.1+ TODO). + sessionId: lease.sessionId, + attachToken: minted.token, + leaseExpiresAt: lease.expiresAt, + // Legacy alias — extensions still on the v1.43 wire shape keep + // working. Drop after one minor release once dogfood confirms. ptySessionToken: minted.token, expiresAt: minted.expiresAt, }), { status: 200, headers: { 'Content-Type': 'application/json', - // Set-Cookie is kept for non-browser callers / future use, - // but the WS upgrade no longer depends on it. 'Set-Cookie': buildPtySetCookie(minted.token), }, }); } + // ─── /pty-session/reattach — mint fresh attachToken for existing sessionId + // + // Used by Commit 3's re-attach loop on the client. Validates the + // lease (rejects unknown/expired sessionId with 410 Gone), mints a + // fresh short-lived attachToken bound to the same sessionId, and + // pushes it to the agent. The client opens a new WS with the new + // token; the agent matches the sessionId binding and re-attaches + // to the existing PtySession (kept alive for the 60s detach + // window — Commit 3 wires that side). + if (url.pathname === '/pty-session/reattach' && req.method === 'POST') { + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + const port = readTerminalPort(); + if (!port) { + return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), { + status: 503, headers: { 'Content-Type': 'application/json' }, + }); + } + let body: any; + try { body = await req.json(); } catch { body = null; } + const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; + const v = sessionId ? validateLease(sessionId) : { ok: false }; + if (!v.ok) { + // 410 Gone — session window has closed (lease expired or never + // existed). Client must fall back to /pty-session for a brand-new + // session. + return new Response(JSON.stringify({ error: 'lease expired or unknown' }), { + status: 410, headers: { 'Content-Type': 'application/json' }, + }); + } + const minted = mintPtySessionToken(); + const granted = await grantPtyToken(minted.token, sessionId!); + if (!granted) { + revokePtySessionToken(minted.token); + return new Response(JSON.stringify({ error: 'failed to grant attach token' }), { + status: 503, headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ + terminalPort: port, + sessionId, + attachToken: minted.token, + leaseExpiresAt: v.ok ? v.expiresAt : 0, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // ─── /pty-restart — one-transaction kill + fresh mint ──────────── + // + // The Restart button. Synchronously disposes the caller's existing + // PtySession on the agent, revokes the old lease, mints a fresh + // sessionId + lease + attachToken, and returns the new 4-tuple in + // one response. Zero race window between kill and mint (codex T2 + // + D8 of the eng review). + if (url.pathname === '/pty-restart' && req.method === 'POST') { + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + const port = readTerminalPort(); + if (!port) { + return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), { + status: 503, headers: { 'Content-Type': 'application/json' }, + }); + } + let body: any; + try { body = await req.json(); } catch { body = null; } + const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; + // Best-effort dispose. Missing/unknown sessionId is non-fatal — + // the client may be doing a "restart from scratch" with no prior + // session (e.g. ENDED state). The fresh mint always proceeds. + if (oldSessionId) { + await restartPtySession(oldSessionId); + revokeLease(oldSessionId); + } + const lease = mintLease(); + const minted = mintPtySessionToken(); + const granted = await grantPtyToken(minted.token, lease.sessionId); + if (!granted) { + revokePtySessionToken(minted.token); + revokeLease(lease.sessionId); + return new Response(JSON.stringify({ error: 'failed to grant terminal session' }), { + status: 503, headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ + terminalPort: port, + sessionId: lease.sessionId, + attachToken: minted.token, + leaseExpiresAt: lease.expiresAt, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // ─── /pty-dispose — explicit teardown (pagehide / browser quit) ── + // + // sendBeacon-compatible: accepts the auth token in the BODY so the + // extension's pagehide handler can fire it without setting headers + // (sendBeacon doesn't support custom headers). Codex T3 fix — + // without this, every browser quit + sidebar close leaves a zombie + // PTY alive for the 60s detach window (Commit 3). + if (url.pathname === '/pty-dispose' && req.method === 'POST') { + let body: any; + try { body = await req.json(); } catch { body = null; } + const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null; + // Accept either header bearer OR body authToken. Both must match + // the root auth token; otherwise reject. + const headerToken = extractToken(req); + const authedByHeader = headerToken !== null && headerToken === authToken; + const authedByBody = authTokenFromBody !== null && authTokenFromBody === authToken; + if (!authedByHeader && !authedByBody) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; + if (sessionId) { + await restartPtySession(sessionId); + revokeLease(sessionId); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // ─── /internal/lease-refresh — loopback from terminal-agent on keepalive + // + // T6 PTY-only idle reset (codex outside-voice fix): the headless + // daemon's idle timer must reset only on active PTY usage, not on + // every passive SSE consumer. Terminal-agent calls this endpoint + // (lazily, only when its cached lease is within 5 min of expiry) + // on its 25s keepalive cycle. Refreshing the lease here also bumps + // lastActivity so the daemon stays alive while a sidebar terminal + // is actively in use. + // + // INTERNAL endpoint — bound to the root authToken so an external + // caller can't refresh another user's lease. Body: {sessionId}. + if (url.pathname === '/internal/lease-refresh' && req.method === 'POST') { + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + let body: any; + try { body = await req.json(); } catch { body = null; } + const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null; + const r = sessionId ? refreshLease(sessionId) : { ok: false }; + if (!r.ok) { + return new Response(JSON.stringify({ error: 'lease expired or unknown' }), { + status: 410, headers: { 'Content-Type': 'application/json' }, + }); + } + // T6: PTY activity resets the daemon idle timer. + resetIdleTimer(); + return new Response(JSON.stringify({ ok: true, expiresAt: r.expiresAt }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + // ─── /pty-inject-scan — pre-inject prompt-injection scan for the // extension's gstackInjectToTerminal callers. The extension routes // every page-derived text through this endpoint BEFORE writing to diff --git a/browse/test/server-pty-lease-routes.test.ts b/browse/test/server-pty-lease-routes.test.ts new file mode 100644 index 000000000..2c1261883 --- /dev/null +++ b/browse/test/server-pty-lease-routes.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Server-side route shape for the v1.44 lease + restart + dispose + +// lease-refresh wiring. Live route exercises require the terminal-agent +// loopback to be live (e2e-tier); these static-grep tripwires pin the +// load-bearing protocol invariants. + +const SERVER_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'server.ts'); + +describe('server: PTY lease routes (v1.44+ Commit 2)', () => { + test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'"); + expect(block).toContain('mintLease()'); + expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); + expect(block).toContain('sessionId: lease.sessionId'); + expect(block).toContain('attachToken: minted.token'); + expect(block).toContain('leaseExpiresAt: lease.expiresAt'); + // Backward compat: legacy ptySessionToken alias preserved for one release. + expect(block).toContain('ptySessionToken: minted.token'); + }); + + test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'"); + // Validate-first: rejects unknown/expired sessionId with 410 Gone so + // the client knows to fall back to a fresh /pty-session. + expect(block).toContain('validateLease(sessionId)'); + expect(block).toContain('status: 410'); + // Mint fresh token bound to SAME sessionId. + expect(block).toContain('grantPtyToken(minted.token, sessionId!)'); + }); + + test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'"); + // Disposes old session (best-effort — missing sessionId is non-fatal). + expect(block).toContain('restartPtySession(oldSessionId)'); + expect(block).toContain('revokeLease(oldSessionId)'); + // Then mints fresh sessionId + lease + attachToken in the same handler. + expect(block).toContain('mintLease()'); + expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)'); + // Returns the same 4-tuple shape so the client doesn't need a + // separate /pty-session round-trip. + expect(block).toContain('attachToken: minted.token'); + expect(block).toContain('leaseExpiresAt: lease.expiresAt'); + }); + + test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'"); + // sendBeacon can't set custom headers, so the route MUST accept the + // auth token in the request body. Otherwise pagehide cleanup fails + // silently every time the user closes the browser. + expect(block).toContain('body?.authToken'); + expect(block).toContain('authedByBody'); + // Both auth paths must validate against authToken — never just trust + // a body-supplied token without the equality check. + expect(block).toContain('authTokenFromBody === authToken'); + }); + + test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan'); + expect(block).toContain('refreshLease(sessionId)'); + expect(block).toContain('resetIdleTimer()'); + // Refresh failure (unknown / expired) MUST 410, not 200, so the + // agent knows to close the WS and force a clean re-auth. + expect(block).toContain('status: 410'); + }); + + test('6. grantPtyToken loopback carries sessionId binding', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/); + expect(src).toContain('sessionId ? { token, sessionId } : { token }'); + }); + + test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => { + const src = fs.readFileSync(SERVER_TS, 'utf-8'); + expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/); + expect(src).toContain('/internal/restart'); + expect(src).toContain('JSON.stringify({ sessionId })'); + }); +}); + +function sliceBetween(source: string, start: string, end: string): string { + const i = source.indexOf(start); + if (i === -1) throw new Error(`marker not found: ${start}`); + const j = source.indexOf(end, i + start.length); + if (j === -1) throw new Error(`end marker not found: ${end}`); + return source.slice(i, j); +}