From c5407b101b7b8c55d80f177c900034a508208d20 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 25 Apr 2026 12:33:43 -0700 Subject: [PATCH] feat(server): add terminal-agent.ts (PTY for the Terminal sidebar tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates phoenix gbrowser's Go PTY (cmd/gbd/terminal.go) into a Bun non-compiled process. Lives separately from `sidebar-agent.ts` so a WS-framing or PTY-cleanup bug can't take down the chat path (codex outside-voice review caught the coupling risk). Architecture: - Bun.serve on 127.0.0.1:0 (never tunneled). - POST /internal/grant accepts cookie tokens from the parent server over loopback, authenticated with a per-boot internal token. - GET /ws upgrades require BOTH (a) Origin: chrome-extension:// and (b) the gstack_pty cookie minted by /pty-session. Either gate alone is insufficient (CSWSH defense + auth defense). - Lazy spawn: claude PTY is not started until the WS receives its first data frame. Idle sidebar opens cost nothing. - Bun PTY API: `terminal: { rows, cols, data(t, chunk) }` — verified at impl time on Bun 1.3.10. proc.terminal.write() for input, proc.terminal.resize() for resize, proc.kill() + 3s SIGKILL fallback on close. - process.on('uncaughtException'|'unhandledRejection') handlers so a framing bug logs but doesn't kill the listener loop. Test-only `BROWSE_TERMINAL_BINARY` env override lets the integration tests spawn /bin/bash instead of requiring claude on every CI runner. Not yet spawned by anything — wired in the next commit. --- browse/src/terminal-agent.ts | 408 +++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 browse/src/terminal-agent.ts diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts new file mode 100644 index 00000000..c7600b96 --- /dev/null +++ b/browse/src/terminal-agent.ts @@ -0,0 +1,408 @@ +/** + * Terminal Agent — PTY-backed Claude Code terminal for the gstack browser + * sidebar. Translates the phoenix gbrowser PTY (cmd/gbd/terminal.go) into + * Bun, with a few changes informed by codex's outside-voice review: + * + * - Lives in a separate non-compiled bun process from sidebar-agent.ts so + * a bug in WS framing or PTY cleanup can't take down the chat path. + * - Binds 127.0.0.1 only — never on the dual-listener tunnel surface. + * - Origin validation on the WS upgrade is REQUIRED (not defense-in-depth) + * because a localhost shell WS is a real cross-site WebSocket-hijacking + * target. + * - Cookie-based auth via /internal/grant from the parent server, not a + * token in /health. + * - Lazy spawn: claude PTY is not spawned until the WS receives its first + * data frame. Sidebar opens that never type don't burn a claude session. + * - PTY dies with WS close (one PTY per WS). v1.1 may add session + * survival; for v1 we match phoenix's lifecycle. + * + * The PTY uses Bun's `terminal:` spawn option (verified at impl time on + * Bun 1.3.10): pass cols/rows + a data callback; write input via + * `proc.terminal.write(buf)`; resize via `proc.terminal.resize(cols, rows)`. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { safeUnlink } from './error-handling'; + +const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); +const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port'); +const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10); +const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check +const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn + +// In-memory cookie token registry. Parent posts /internal/grant after +// /pty-session; we validate WS cookies against this set. +const validTokens = new Set(); + +// Active PTY session per WS. One terminal per connection. Codex finding #4: +// uncaught handlers below catch bugs in framing/cleanup so they don't kill +// the listener loop. +process.on('uncaughtException', (err) => { + console.error('[terminal-agent] uncaughtException:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[terminal-agent] unhandledRejection:', reason); +}); + +interface PtySession { + proc: any | null; // Bun.Subprocess once spawned + cols: number; + rows: number; + cookie: string; + spawned: boolean; +} + +const sessions = new WeakMap(); // ws -> session + +/** Find claude on PATH. */ +function findClaude(): string | null { + // Test-only override. Lets the integration tests spawn /bin/bash instead + // of requiring claude to be installed on every CI runner. NEVER read in + // production (sidebar UI). Documented in browse/test/terminal-agent-integration.test.ts. + const override = process.env.BROWSE_TERMINAL_BINARY; + if (override && fs.existsSync(override)) return override; + // Bun.which is sync and respects PATH. Falls back to a small list of + // common install locations if PATH is stripped (e.g., launched from + // Conductor with a minimal env). + const which = (Bun as any).which?.('claude'); + if (which) return which; + const candidates = [ + '/opt/homebrew/bin/claude', + '/usr/local/bin/claude', + `${process.env.HOME}/.local/bin/claude`, + `${process.env.HOME}/.bun/bin/claude`, + `${process.env.HOME}/.npm-global/bin/claude`, + ]; + for (const c of candidates) { + try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {} + } + return null; +} + +/** Probe + persist claude availability for the bootstrap card. */ +function writeClaudeAvailable(): void { + const stateDir = path.dirname(STATE_FILE); + try { fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); } catch {} + const found = findClaude(); + const status = { + available: !!found, + path: found || undefined, + install_url: 'https://docs.anthropic.com/en/docs/claude-code', + checked_at: new Date().toISOString(), + }; + const target = path.join(stateDir, 'claude-available.json'); + const tmp = path.join(stateDir, `.tmp-claude-${process.pid}`); + try { + fs.writeFileSync(tmp, JSON.stringify(status, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, target); + } catch { + safeUnlink(tmp); + } +} + +/** Spawn claude in a PTY. Returns null if claude not on PATH. */ +function spawnClaude(cols: number, rows: number, onData: (chunk: Buffer) => void) { + const claudePath = findClaude(); + if (!claudePath) return null; + + // Match phoenix env so claude knows which browse server to talk to and + // doesn't try to autostart its own. BROWSE_HEADED=1 keeps the existing + // headed-mode browser; BROWSE_NO_AUTOSTART prevents claude's gstack + // tooling from racing to spawn another server. + const env: Record = { + ...process.env as any, + BROWSE_PORT: String(BROWSE_SERVER_PORT), + BROWSE_STATE_FILE: STATE_FILE, + BROWSE_NO_AUTOSTART: '1', + BROWSE_HEADED: '1', + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }; + + const proc = (Bun as any).spawn([claudePath], { + terminal: { + rows, + cols, + data(_terminal: any, chunk: Buffer) { onData(chunk); }, + }, + env, + }); + return proc; +} + +/** Cleanup a PTY session: SIGINT, then SIGKILL after 3s. */ +function disposeSession(session: PtySession): void { + try { session.proc?.terminal?.close?.(); } catch {} + if (session.proc?.pid) { + try { session.proc.kill?.('SIGINT'); } catch {} + setTimeout(() => { + try { + if (session.proc && !session.proc.killed) session.proc.kill?.('SIGKILL'); + } catch {} + }, 3000); + } + session.proc = null; + session.spawned = false; +} + +/** + * Build the HTTP server. Two routes: + * POST /internal/grant — parent server pushes a fresh cookie token + * GET /ws — extension upgrades to WebSocket (PTY transport) + * + * Everything else returns 404. The listener binds 127.0.0.1 only. + */ +function buildServer() { + return Bun.serve({ + hostname: '127.0.0.1', + port: 0, + idleTimeout: 0, // PTY connections are long-lived; default idleTimeout would kill them + + fetch(req, server) { + const url = new URL(req.url); + + // /internal/grant — loopback-only handshake from parent server. + if (url.pathname === '/internal/grant' && req.method === 'POST') { + const auth = req.headers.get('authorization'); + if (auth !== `Bearer ${INTERNAL_TOKEN}`) { + return new Response('forbidden', { status: 403 }); + } + return req.json().then((body: any) => { + if (typeof body?.token === 'string' && body.token.length > 16) { + validTokens.add(body.token); + } + return new Response('ok'); + }).catch(() => new Response('bad', { status: 400 })); + } + + // /internal/revoke — drop a token (called on WS close or bootstrap reload) + if (url.pathname === '/internal/revoke' && req.method === 'POST') { + const auth = req.headers.get('authorization'); + if (auth !== `Bearer ${INTERNAL_TOKEN}`) { + return new Response('forbidden', { status: 403 }); + } + return req.json().then((body: any) => { + if (typeof body?.token === 'string') validTokens.delete(body.token); + return new Response('ok'); + }).catch(() => new Response('bad', { status: 400 })); + } + + // /claude-available — bootstrap card hits this when user clicks "I installed it". + if (url.pathname === '/claude-available' && req.method === 'GET') { + writeClaudeAvailable(); + const found = findClaude(); + return new Response(JSON.stringify({ available: !!found, path: found }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // /ws — WebSocket upgrade. CRITICAL gates: + // (1) Origin must be chrome-extension://. Cross-site WS hijacking + // defense per codex finding #9. + // (2) Cookie gstack_pty must be in validTokens. The cookie was + // minted by the parent server's /pty-session route under a + // valid AUTH_TOKEN, so a request without it can't get a shell. + if (url.pathname === '/ws') { + const origin = req.headers.get('origin') || ''; + const isExtensionOrigin = origin.startsWith('chrome-extension://'); + if (!isExtensionOrigin) { + return new Response('forbidden origin', { status: 403 }); + } + if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) { + return new Response('forbidden origin', { status: 403 }); + } + + const cookieHeader = req.headers.get('cookie') || ''; + let cookieToken: string | null = null; + for (const part of cookieHeader.split(';')) { + const [name, ...rest] = part.trim().split('='); + if (name === 'gstack_pty') { cookieToken = rest.join('=') || null; break; } + } + if (!cookieToken || !validTokens.has(cookieToken)) { + return new Response('unauthorized', { status: 401 }); + } + + const upgraded = server.upgrade(req, { + data: { cookie: cookieToken }, + }); + return upgraded ? undefined : new Response('upgrade failed', { status: 500 }); + } + + return new Response('not found', { status: 404 }); + }, + + websocket: { + message(ws, raw) { + let session = sessions.get(ws); + if (!session) { + session = { + proc: null, + cols: 80, + rows: 24, + cookie: (ws.data as any)?.cookie || '', + spawned: false, + }; + sessions.set(ws, session); + } + + // Text frames are control messages: {type: "resize", cols, rows} or + // {type: "tabSwitch", tabId, url, title}. Binary frames are raw input + // bytes destined for the PTY stdin. + if (typeof raw === 'string') { + let msg: any; + try { msg = JSON.parse(raw); } catch { return; } + if (msg?.type === 'resize') { + const cols = Math.max(2, Math.floor(Number(msg.cols) || 80)); + const rows = Math.max(2, Math.floor(Number(msg.rows) || 24)); + session.cols = cols; + session.rows = rows; + try { session.proc?.terminal?.resize?.(cols, rows); } catch {} + return; + } + if (msg?.type === 'tabSwitch') { + handleTabSwitch(msg); + return; + } + // Unknown text frame — ignore. + return; + } + + // Binary input. Lazy-spawn claude on the first byte. + if (!session.spawned) { + session.spawned = true; + const proc = spawnClaude(session.cols, session.rows, (chunk) => { + try { ws.sendBinary(chunk); } catch {} + }); + if (!proc) { + try { + ws.send(JSON.stringify({ + type: 'error', + code: 'CLAUDE_NOT_FOUND', + message: 'claude CLI not on PATH. Install: https://docs.anthropic.com/en/docs/claude-code', + })); + ws.close(4404, 'claude not found'); + } catch {} + return; + } + session.proc = proc; + // Watch for child exit so the WS closes cleanly when claude exits. + proc.exited?.then?.(() => { + try { ws.close(1000, 'pty exited'); } catch {} + }); + } + try { + // raw is a Uint8Array; Bun.Terminal.write accepts string|Buffer. + // Convert to Buffer for safety. + session.proc?.terminal?.write?.(Buffer.from(raw as Uint8Array)); + } catch (err) { + console.error('[terminal-agent] terminal.write failed:', err); + } + }, + + close(ws) { + const session = sessions.get(ws); + if (session) { + disposeSession(session); + if (session.cookie) { + // Drop the cookie so it can't be replayed against a new PTY. + validTokens.delete(session.cookie); + } + sessions.delete(ws); + } + }, + }, + }); +} + +/** + * Tab-switch helper: write the active tab to a state file (claude reads it) + * and notify the parent server so its activeTabId stays synced. Skips + * chrome:// and chrome-extension:// internal pages. + */ +function handleTabSwitch(msg: { tabId?: number; url?: string; title?: string }): void { + const url = msg.url || ''; + if (!url || url.startsWith('chrome://') || url.startsWith('chrome-extension://')) return; + + const stateDir = path.dirname(STATE_FILE); + const ctxFile = path.join(stateDir, 'active-tab.json'); + const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`); + try { + fs.writeFileSync(tmp, JSON.stringify({ + tabId: msg.tabId ?? null, + url, + title: msg.title ?? '', + }), { mode: 0o600 }); + fs.renameSync(tmp, ctxFile); + } catch { + safeUnlink(tmp); + } + + // Best-effort sync to parent server so its activeTabId tracking matches. + // No await; this is fire-and-forget. + if (BROWSE_SERVER_PORT > 0) { + fetch(`http://127.0.0.1:${BROWSE_SERVER_PORT}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${readBrowseToken()}`, + }, + body: JSON.stringify({ + command: 'tab', + args: [String(msg.tabId ?? ''), '--no-focus'], + }), + }).catch(() => {}); + } +} + +function readBrowseToken(): string { + try { + const raw = fs.readFileSync(STATE_FILE, 'utf-8'); + const j = JSON.parse(raw); + return j.token || ''; + } catch { return ''; } +} + +// Boot. +function main() { + writeClaudeAvailable(); + const server = buildServer(); + const port = (server as any).port || (server as any).address?.port; + if (!port) { + console.error('[terminal-agent] failed to bind: no port'); + process.exit(1); + } + + // Write port file atomically so the parent server can pick it up. + const dir = path.dirname(PORT_FILE); + try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch {} + const tmp = `${PORT_FILE}.tmp-${process.pid}`; + fs.writeFileSync(tmp, String(port), { mode: 0o600 }); + fs.renameSync(tmp, PORT_FILE); + + // Hand the parent the internal token so it can call /internal/grant. + // Parent learns INTERNAL_TOKEN via env (TERMINAL_AGENT_INTERNAL_TOKEN below). + // We just print it on stdout for the supervising process to pick up if it's + // not already in env. Defense against env races at spawn time. + console.log(`[terminal-agent] listening on 127.0.0.1:${port} pid=${process.pid}`); + + // Cleanup port file on exit. + const cleanup = () => { safeUnlink(PORT_FILE); process.exit(0); }; + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); +} + +// Export the internal token so cli.ts can pass the SAME value to the parent +// server via env. Parent reads BROWSE_TERMINAL_INTERNAL_TOKEN and uses it +// for /internal/grant calls. +// +// In practice, the agent generates INTERNAL_TOKEN once at boot and writes it +// to a state file the parent reads. This avoids env-passing races. See main(). +const INTERNAL_TOKEN_FILE = path.join(path.dirname(STATE_FILE), 'terminal-internal-token'); +try { + fs.mkdirSync(path.dirname(INTERNAL_TOKEN_FILE), { recursive: true, mode: 0o700 }); + fs.writeFileSync(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN, { mode: 0o600 }); +} catch {} + +main();