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); +}