From d596232248bb74cff661c4223e714cbc0cc4b6dd Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:18:18 -0700 Subject: [PATCH] refactor: remove in-browser PTY terminal Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/cli.ts | 40 +- browse/src/pty-session-cookie.ts | 122 -- browse/src/pty-session-lease.ts | 137 --- browse/src/server.ts | 449 +------- browse/src/sidebar-utils.ts | 21 - browse/src/terminal-agent-control.ts | 143 --- browse/src/terminal-agent.ts | 1011 ----------------- browse/test/dual-listener.test.ts | 4 +- browse/test/pty-session-lease.test.ts | 98 -- browse/test/security-sidepanel-dom.test.ts | 265 ----- browse/test/server-auth.test.ts | 4 +- .../server-embedder-terminal-port.test.ts | 232 ---- browse/test/server-pty-lease-routes.test.ts | 94 -- browse/test/sidebar-integration.test.ts | 122 -- browse/test/sidebar-security.test.ts | 134 --- browse/test/sidebar-tabs.test.ts | 270 ----- browse/test/sidebar-unit.test.ts | 96 -- browse/test/sidebar-ux.test.ts | 240 ---- .../sidepanel-patient-autoconnect.test.ts | 70 -- browse/test/sidepanel-reattach.test.ts | 93 -- browse/test/sidepanel-restart-dispose.test.ts | 106 -- .../terminal-agent-detach-reattach.test.ts | 127 --- .../test/terminal-agent-integration.test.ts | 273 ----- .../terminal-agent-internal-handler.test.ts | 51 - browse/test/terminal-agent-keepalive.test.ts | 88 -- .../test/terminal-agent-pid-identity.test.ts | 161 --- ...terminal-agent-ring-buffer-runtime.test.ts | 155 --- .../terminal-agent-session-routing.test.ts | 96 -- browse/test/terminal-agent-watchdog.test.ts | 91 -- browse/test/terminal-agent.test.ts | 258 ----- test/extension-pty-inject-invariant.test.ts | 141 --- test/skill-e2e-sidebar.test.ts | 471 -------- 32 files changed, 11 insertions(+), 5652 deletions(-) delete mode 100644 browse/src/pty-session-cookie.ts delete mode 100644 browse/src/pty-session-lease.ts delete mode 100644 browse/src/sidebar-utils.ts delete mode 100644 browse/src/terminal-agent-control.ts delete mode 100644 browse/src/terminal-agent.ts delete mode 100644 browse/test/pty-session-lease.test.ts delete mode 100644 browse/test/security-sidepanel-dom.test.ts delete mode 100644 browse/test/server-embedder-terminal-port.test.ts delete mode 100644 browse/test/server-pty-lease-routes.test.ts delete mode 100644 browse/test/sidebar-integration.test.ts delete mode 100644 browse/test/sidebar-security.test.ts delete mode 100644 browse/test/sidebar-tabs.test.ts delete mode 100644 browse/test/sidebar-unit.test.ts delete mode 100644 browse/test/sidebar-ux.test.ts delete mode 100644 browse/test/sidepanel-patient-autoconnect.test.ts delete mode 100644 browse/test/sidepanel-reattach.test.ts delete mode 100644 browse/test/sidepanel-restart-dispose.test.ts delete mode 100644 browse/test/terminal-agent-detach-reattach.test.ts delete mode 100644 browse/test/terminal-agent-integration.test.ts delete mode 100644 browse/test/terminal-agent-internal-handler.test.ts delete mode 100644 browse/test/terminal-agent-keepalive.test.ts delete mode 100644 browse/test/terminal-agent-pid-identity.test.ts delete mode 100644 browse/test/terminal-agent-ring-buffer-runtime.test.ts delete mode 100644 browse/test/terminal-agent-session-routing.test.ts delete mode 100644 browse/test/terminal-agent-watchdog.test.ts delete mode 100644 browse/test/terminal-agent.test.ts delete mode 100644 test/extension-pty-inject-invariant.test.ts delete mode 100644 test/skill-e2e-sidebar.test.ts diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 93e4f17da..256cf6891 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -17,7 +17,6 @@ import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; -import { spawnTerminalAgent } from './terminal-agent-control'; const config = resolveConfig(); const IS_WINDOWS = process.platform === 'win32'; @@ -1095,14 +1094,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // Delete stale state file safeUnlinkQuiet(config.stateFile); - console.log('Launching headed Chromium with extension + terminal agent...'); + console.log('Launching headed Chromium...'); try { - // Start server in headed mode with extension auto-loaded - // Use a well-known port so the Chrome extension auto-connects + // Start server in headed mode. + // Use a well-known port so callers auto-connect. const serverEnv: Record = { BROWSE_HEADED: '1', BROWSE_PORT: '34567', - BROWSE_SIDEBAR_CHAT: '1', // Disable parent-process watchdog: the user controls the headed browser // window lifecycle. The CLI exits immediately after connect, so watching // it would kill the server ~15s later. Cleanup happens via browser @@ -1134,28 +1132,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: console.log('(If you still don\'t see it, check Mission Control / other Spaces.)'); } - // sidebar-agent.ts spawn was here. Ripped alongside the chat queue — - // the Terminal pane runs an interactive PTY now, no more one-shot - // claude -p subprocesses to multiplex. - - // Auto-start terminal agent (non-compiled bun process). Owns the PTY - // WebSocket for the sidebar Terminal pane. Routes through the shared - // spawnTerminalAgent helper so the CLI cold-start path and the - // server.ts watchdog respawn path share one implementation. The - // helper handles prior-PID cleanup, script lookup, and env wiring. - try { - const newPid = spawnTerminalAgent({ - stateFile: config.stateFile, - serverPort: newState.port, - cwd: config.projectDir, - }); - if (newPid) { - console.log(`[browse] Terminal agent started (PID: ${newPid})`); - } - } catch (err: any) { - // Non-fatal: chat still works without the terminal agent. - console.error(`[browse] Terminal agent failed to start: ${err.message}`); - } } catch (err: any) { console.error(`[browse] Connect failed: ${err.message}`); process.exit(1); @@ -1234,16 +1210,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: try { const respawned = await startServer(serverEnv); console.log(`[browse] Supervisor: server respawned (PID ${respawned.pid}, port ${respawned.port}).`); - // Re-spawn the terminal-agent too; same env wiring as the initial connect. - try { - spawnTerminalAgent({ - stateFile: config.stateFile, - serverPort: respawned.port, - cwd: config.projectDir, - }); - } catch (err: any) { - console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`); - } } catch (err: any) { console.error(`[browse] Supervisor: server respawn failed: ${err?.message || err}`); // Let the next tick try again — the crash-loop guard already diff --git a/browse/src/pty-session-cookie.ts b/browse/src/pty-session-cookie.ts deleted file mode 100644 index 8871fe471..000000000 --- a/browse/src/pty-session-cookie.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Session cookie registry for the Terminal sidebar tab's PTY WebSocket. - * - * Why this exists: WebSocket clients in browsers cannot send Authorization - * headers on the upgrade request. The terminal-agent's /ws upgrade therefore - * authenticates via cookie. We never put the PTY token in /health (codex - * outside-voice finding #2: /health already leaks AUTH_TOKEN to any - * localhost caller in headed mode; reusing that path for shell access would - * widen an existing bug). Instead, the extension does an authenticated - * POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a - * short-lived cookie scoped to this terminal session and pushes it to the - * agent via loopback. The browser then carries the cookie automatically on - * the WS upgrade. - * - * Design mirrors `sse-session-cookie.ts` deliberately. Same TTL, same - * scoped-token-must-not-be-valid-as-root invariant, same opportunistic - * pruning. Two registries instead of one because the cookie names are - * different (`gstack_sse` vs `gstack_pty`) and the token spaces must not - * overlap — an SSE-read cookie must never grant PTY access, and vice versa. - */ -import * as crypto from 'crypto'; - -interface Session { - createdAt: number; - expiresAt: number; -} - -const TTL_MS = 30 * 60 * 1000; // 30 minutes — matches SSE cookie -const MAX_SESSIONS = 10_000; -const sessions = new Map(); - -export const PTY_COOKIE_NAME = 'gstack_pty'; - -/** Mint a fresh PTY session token. */ -export function mintPtySessionToken(): { token: string; expiresAt: number } { - const token = crypto.randomBytes(32).toString('base64url'); - const now = Date.now(); - const expiresAt = now + TTL_MS; - sessions.set(token, { createdAt: now, expiresAt }); - pruneExpired(now); - return { token, expiresAt }; -} - -/** - * Validate a token. Returns true only if the token exists AND is not expired. - * Lazily removes expired entries; opportunistically prunes a few more on - * every call so the registry stays bounded under reconnect pressure. - */ -export function validatePtySessionToken(token: string | null | undefined): boolean { - if (!token) return false; - const s = sessions.get(token); - if (!s) { - pruneExpired(Date.now()); - return false; - } - if (Date.now() > s.expiresAt) { - sessions.delete(token); - pruneExpired(Date.now()); - return false; - } - return true; -} - -/** - * Drop a session token (called on WS close so a leaked cookie can't be - * replayed against a new PTY). - */ -export function revokePtySessionToken(token: string | null | undefined): void { - if (!token) return; - sessions.delete(token); -} - -/** Parse the PTY session token from a Cookie header. */ -export function extractPtyCookie(req: Request): string | null { - const cookieHeader = req.headers.get('cookie'); - if (!cookieHeader) return null; - for (const part of cookieHeader.split(';')) { - const [name, ...valueParts] = part.trim().split('='); - if (name === PTY_COOKIE_NAME) { - return valueParts.join('=') || null; - } - } - return null; -} - -/** - * Build the Set-Cookie header value for the PTY session cookie. - * - HttpOnly: not readable from JS (mitigates XSS exfiltration). - * - SameSite=Strict: not sent on cross-site requests (mitigates CSWSH). - * - Path=/: scope to whole origin so /ws and /pty-session both see it. - * - Max-Age matches the TTL. - * - * Secure is intentionally omitted: the daemon binds to 127.0.0.1 over plain - * HTTP; setting Secure would prevent the browser from ever sending it back. - */ -export function buildPtySetCookie(token: string): string { - const maxAge = Math.floor(TTL_MS / 1000); - return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; -} - -/** Clear the PTY session cookie. */ -export function buildPtyClearCookie(): string { - return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`; -} - -function pruneExpired(now: number): void { - let checked = 0; - for (const [token, session] of sessions) { - if (checked++ >= 20) break; - if (session.expiresAt <= now) sessions.delete(token); - } - while (sessions.size > MAX_SESSIONS) { - const first = sessions.keys().next().value; - if (!first) break; - sessions.delete(first); - } -} - -// Test-only reset. -export function __resetPtySessions(): void { - sessions.clear(); -} diff --git a/browse/src/pty-session-lease.ts b/browse/src/pty-session-lease.ts deleted file mode 100644 index ec2797889..000000000 --- a/browse/src/pty-session-lease.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * PTY session lease registry (v1.44+). - * - * Separates two concerns that pre-v1.44 were conflated under one token: - * - * - **sessionId** — stable, non-secret identifier for a single PTY session. - * Safe to log, safe to include in URLs and server access logs, safe to - * keep in DevTools. Identifies "this terminal," not "you're allowed to - * use this terminal." - * - * - **attachToken** — secret, short-lived (30 s) bearer credential that - * grants the WS upgrade for ONE attach attempt against a session. Minted - * on every /pty-session and /pty-session/reattach call; revoked when - * the WS upgrade consumes it. Kept out of logs. - * - * - **lease** — server-side bookkeeping that maps sessionId → expiresAt. - * Re-attach within the lease window resumes the same PTY (and replays - * the ring buffer from terminal-agent). Lease expiry tears down the - * session. - * - * Codex outside-voice (T1 of the eng review) pushed for this separation: - * "the auth token IS the session id" collapsed identity into a secret, - * meaning re-attach URLs and logs carry the bearer credential. The lease - * model fixes that without changing the user experience. - * - * Mint cadence: - * - Initial /pty-session: mint sessionId + lease + attachToken (one round trip). - * - /pty-session/reattach: validate sessionId/lease, mint fresh attachToken. - * - /pty-restart: revoke old lease, mint fresh sessionId + lease + attachToken. - * - /pty-dispose: revoke lease (and the terminal-agent disposes the PTY). - * - * Lease TTL is env-overridable so v1.44 e2e tests can compress detach - * windows to 1 s instead of waiting 30 minutes per assertion. - */ -import * as crypto from 'crypto'; - -interface Lease { - createdAt: number; - expiresAt: number; -} - -const LEASE_TTL_MS = parseInt( - process.env.GSTACK_PTY_LEASE_TTL_MS || `${30 * 60 * 1000}`, - 10, -); // 30 minutes default; covers idle-but-engaged user sessions -const MAX_LEASES = 10_000; -const leases = new Map(); - -/** - * Mint a fresh sessionId + lease. Returns the non-secret sessionId and - * the expiry timestamp (caller surfaces both to the client). Never throws. - */ -export function mintLease(): { sessionId: string; expiresAt: number } { - const sessionId = crypto.randomBytes(32).toString('base64url'); - const now = Date.now(); - const expiresAt = now + LEASE_TTL_MS; - leases.set(sessionId, { createdAt: now, expiresAt }); - pruneExpired(now); - return { sessionId, expiresAt }; -} - -/** - * Check whether a lease is still valid (exists AND not expired). Returns - * the current expiresAt for valid leases; null otherwise. Lazily prunes - * stale entries. - */ -export function validateLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { - if (!sessionId) return { ok: false }; - const lease = leases.get(sessionId); - if (!lease) { - pruneExpired(Date.now()); - return { ok: false }; - } - if (Date.now() > lease.expiresAt) { - leases.delete(sessionId); - pruneExpired(Date.now()); - return { ok: false }; - } - return { ok: true, expiresAt: lease.expiresAt }; -} - -/** - * Extend the lease's expiresAt to `now + LEASE_TTL_MS`. Caller should - * gate refresh on `expiresAt - now < REFRESH_THRESHOLD` (D10 lazy - * refresh: avoid refreshing on every keepalive when the lease is - * comfortably far from expiry). - * - * Returns `{ ok: true, expiresAt }` on success, `{ ok: false }` if the - * lease is unknown or already expired (the agent must close the WS and - * surface auth-invalid). Critical security invariant: never resurrect - * an expired lease — the 30-min TTL is what bounds blast radius for a - * leaked attach token whose lease should have been GC'd. - */ -export function refreshLease(sessionId: string | null | undefined): { ok: true; expiresAt: number } | { ok: false } { - if (!sessionId) return { ok: false }; - const lease = leases.get(sessionId); - if (!lease) return { ok: false }; - const now = Date.now(); - if (now > lease.expiresAt) { - leases.delete(sessionId); - return { ok: false }; - } - lease.expiresAt = now + LEASE_TTL_MS; - return { ok: true, expiresAt: lease.expiresAt }; -} - -/** - * Drop a lease. Called on explicit dispose (/pty-dispose, /pty-restart, - * WS close with code 4001) and on session timeout in terminal-agent. - */ -export function revokeLease(sessionId: string | null | undefined): void { - if (!sessionId) return; - leases.delete(sessionId); -} - -/** Returns the lease count — test + observability helper. */ -export function leaseCount(): number { - return leases.size; -} - -/** Test-only reset. */ -export function __resetLeases(): void { - leases.clear(); -} - -function pruneExpired(now: number): void { - let checked = 0; - for (const [sessionId, lease] of leases) { - if (checked++ >= 20) break; - if (lease.expiresAt <= now) leases.delete(sessionId); - } - while (leases.size > MAX_LEASES) { - const first = leases.keys().next().value; - if (!first) break; - leases.delete(first); - } -} diff --git a/browse/src/server.ts b/browse/src/server.ts index ca7ef19eb..e8c8220ba 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -18,7 +18,6 @@ import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; -import { sanitizeExtensionUrl } from './sidebar-utils'; import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, @@ -44,8 +43,6 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling'; -import { readAgentRecord, killAgentByRecord, clearAgentRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control'; -import { isProcessAlive } from './error-handling'; import { sanitizeBody, stripLoneSurrogateEscapes } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; @@ -56,12 +53,6 @@ import { mintSseSessionToken, validateSseSessionToken, extractSseCookie, buildSseSetCookie, SSE_COOKIE_NAME, } from './sse-session-cookie'; -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'; @@ -211,38 +202,6 @@ export interface ServerConfig { * dispatch; returning null falls through. */ beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise; - /** - * Whether gstack owns the lifecycle of the terminal-agent process and its - * discovery files (`/terminal-port`, `/terminal-internal-token`, - * `/terminal-agent-pid`). - * - * When true (default), shutdown() runs four side effects: - * 1. Identity-based kill via `killAgentByRecord(readAgentRecord(stateDir))` - * (v1.44+). Only signals the PID recorded by THIS daemon's agent. - * Replaced the historical `pkill -f terminal-agent\.ts` regex that - * matched sibling gstack sessions on the same host — see - * terminal-agent-control.ts for rationale. - * 2. `safeUnlinkQuiet(/terminal-port)` - * 3. `safeUnlinkQuiet(/terminal-internal-token)` - * 4. `safeUnlinkQuiet(/terminal-agent-pid)` (the v1.44 record) - * - * This is correct for gstack's CLI path, which spawns `terminal-agent.ts` as - * the producer of those files (see cli.ts:1037-1063). - * - * Embedders (gbrowser phoenix overlay, future hosts) that run their own PTY - * server and write those files themselves should pass `false`. When `false`, - * the embedder owns BOTH the agent process AND all three discovery files. - * Note that terminal-agent.ts's own SIGTERM cleanup removes `terminal-port` - * and `terminal-agent-pid` (the agent writes both at boot), so embedders - * that pre-launch their own agent must ensure their cleanup matches. - * - * Polarity note: this differs from `xvfb?` and `proxyBridge?`, which gate by - * the *presence* of a caller-owned handle (presence ⇒ don't close). This - * field gates by an explicit boolean because there is no handle object — - * the terminal-agent is started elsewhere (cli.ts), and shutdown's only - * reference is the PID record + the file paths. - */ - ownsTerminalAgent?: boolean; } /** @@ -253,7 +212,7 @@ export interface ServerHandle { fetchLocal: (req: Request, server: any) => Promise; fetchTunnel: (req: Request, server: any) => Promise; /** - * Drains buffers, kills terminal-agent, closes browser, clears intervals, + * Drains buffers, closes browser, clears intervals, * removes state files. Does NOT stop bound Bun.Server listeners — call * stopListeners() for that. CLI relies on process.exit() to drop sockets. */ @@ -302,7 +261,6 @@ export function resolveConfigFromEnv(): Omit([ '/connect', '/command', - '/sidebar-chat', ]); /** @@ -395,77 +353,6 @@ async function closeTunnel(): Promise { // in buildFetchHandler closes over cfg.authToken so every internal auth check // sees the same token the routes receive. -/** - * Terminal-agent discovery. The non-compiled bun process at - * `browse/src/terminal-agent.ts` writes its chosen port to - * `/terminal-port` and the loopback handshake token to - * `/terminal-internal-token` once it boots. Read on demand — - * lazy so we don't break tests that don't spawn the agent. - */ -function readTerminalPort(): number | null { - try { - const f = path.join(path.dirname(config.stateFile), 'terminal-port'); - const v = parseInt(fs.readFileSync(f, 'utf-8').trim(), 10); - return Number.isFinite(v) && v > 0 ? v : null; - } catch { return null; } -} -function readTerminalInternalToken(): string | null { - try { - const f = path.join(path.dirname(config.stateFile), 'terminal-internal-token'); - const t = fs.readFileSync(f, 'utf-8').trim(); - return t.length > 16 ? t : null; - } catch { return null; } -} - -/** - * Push a freshly-minted PTY cookie token to the terminal-agent so its - * /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, 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/grant`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${internal}`, - }, - 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'); @@ -1450,11 +1337,9 @@ if (import.meta.main) { /** * Build a request handler set for the browse daemon. Embedders (gbrowser * phoenix overlay) call this directly with their own cfg to compose overlay - * routes via cfg.beforeRoute, pass a pre-launched cfg.browserManager, and - * opt out of terminal-agent teardown via cfg.ownsTerminalAgent (default - * true, set to false when the embedder runs its own PTY server). The CLI - * path calls this through start() with env-derived defaults and explicit - * cfg.ownsTerminalAgent: true — externally-observable behavior is identical. + * routes via cfg.beforeRoute and pass a pre-launched cfg.browserManager. The + * CLI path calls this through start() with env-derived defaults — + * externally-observable behavior is identical. * * Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the * single source of truth for the bearer secret, factory-scoped validateAuth @@ -1484,89 +1369,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { initRegistry(cfg.authToken); const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg; - // Strict opt-out: only explicit `false` flips the gate. Any other value - // (undefined, truthy non-bool from a JS caller bypassing TS, etc.) defaults - // to gstack-owns. Matches the "default-true preserves CLI bit-for-bit" - // premise even under malformed cfg. - const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true; - - // ─── Terminal-Agent Watchdog (v1.44+) ───────────────────────────── - // - // The terminal-agent process can die independently of the server: SIGKILL - // from the OS OOM killer, an uncaught exception under load, an external - // `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would - // see the broken connection and stay broken until the user reloaded. - // Now: 60s ticker checks the recorded agent PID, respawns via the shared - // spawnTerminalAgent helper if dead. - // - // Identity-based — uses readAgentRecord + isProcessAlive, NOT a process - // name probe. Critical: prevents respawning around a slow-but-alive agent - // (which would create split-brain — two agents writing the port file, - // tokens diverging between them, mystery PTY upgrade failures). - // - // Crash-loop guard: 3 respawn attempts inside 60s → stop trying and emit - // a one-line error. Manual `forceRestart` from the sidebar clears the - // history (the user is the explicit signal to retry). - // - // Only active when ownsTerminalAgent === true. Embedders that pre-launch - // their own PTY server (gbrowser phoenix overlay) must not be auto-respawned - // by us — their lifecycle is their concern. - let agentWatchdogInterval: ReturnType | null = null; - const respawnHistory: number[] = []; - const AGENT_WATCHDOG_TICK_MS = parseInt( - process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000', - 10, - ); - const RESPAWN_GUARD_WINDOW_MS = 60_000; - const RESPAWN_GUARD_MAX = 3; - let agentRespawnGuardTripped = false; - - if (ownsTerminalAgent) { - agentWatchdogInterval = setInterval(() => { - if (isShuttingDown) return; - if (agentRespawnGuardTripped) return; - const stateDir = path.dirname(cfg.config.stateFile); - const record = readAgentRecord(stateDir); - // If the record exists and the PID is alive, the agent is healthy - // (or at least still answering signal 0). Slow-but-alive agents - // intentionally fall through here — split-brain is worse than - // unresponsiveness, and slow recovery is handled by the user via - // restart. - if (record && isProcessAlive(record.pid)) return; - // Either no record (never spawned, or cleaned up after crash) or - // PID is dead. Try to respawn. - const now = Date.now(); - while (respawnHistory.length && now - respawnHistory[0] > RESPAWN_GUARD_WINDOW_MS) { - respawnHistory.shift(); - } - if (respawnHistory.length >= RESPAWN_GUARD_MAX) { - agentRespawnGuardTripped = true; - console.error( - `[browse] terminal-agent respawn guard tripped (${RESPAWN_GUARD_MAX} crashes in ${RESPAWN_GUARD_WINDOW_MS / 1000}s) — manual restart required`, - ); - return; - } - respawnHistory.push(now); - try { - const pid = spawnTerminalAgent({ - stateFile: cfg.config.stateFile, - serverPort: cfg.browsePort, - cwd: cfg.config.projectDir, - }); - if (pid) { - console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`); - } else { - console.warn('[browse] terminal-agent respawn skipped — script not found on disk'); - } - } catch (err: any) { - console.warn('[browse] terminal-agent respawn failed:', err?.message || err); - } - }, AGENT_WATCHDOG_TICK_MS); - // Detach the watchdog timer from Node's event-loop ref count so a - // healthy idle process can still exit cleanly if everything else is - // also unref'd. Bun's setInterval returns a Timer with unref(). - (agentWatchdogInterval as any)?.unref?.(); - } // Factory-scoped validateAuth. Closes over cfg.authToken so every internal // auth check sees the same token the routes receive. Module-level @@ -1595,25 +1397,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // a daemon that no longer exists. The path must come from this factory's // config so embedded/isolated servers never clean a sibling session. const shutdownStateFile = cfg.config.stateFile; - const shutdownStateDir = path.dirname(shutdownStateFile); safeUnlinkQuiet(shutdownStateFile); console.log('[browse] Shutting down...'); - if (ownsTerminalAgent) { - // Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f - // terminal-agent\.ts` regex teardown which matched sibling gstack - // sessions on the same host. Only the PID recorded in - // `/terminal-agent-pid` by THIS daemon's agent is signaled. - try { - const record = readAgentRecord(shutdownStateDir); - if (record) killAgentByRecord(record, 'SIGTERM'); - } catch (err: any) { - console.warn('[browse] Failed to kill terminal-agent:', err.message); - } - safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-port')); - safeUnlinkQuiet(path.join(shutdownStateDir, 'terminal-internal-token')); - safeUnlinkQuiet(agentRecordPath(shutdownStateDir)); - } try { detachSession(); } catch (err: any) { console.warn('[browse] Failed to detach CDP session:', err.message); } @@ -1621,7 +1407,6 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch(); clearInterval(flushInterval); clearInterval(idleCheckInterval); - if (agentWatchdogInterval) clearInterval(agentWatchdogInterval); await flushBuffers(); await cfgBrowserManager.close(); @@ -1815,237 +1600,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // sidebar-agent.ts was ripped; only the page-content side // (canary, content-security) keeps reporting in. security: getSecurityStatus(), - // Terminal-agent discovery. ONLY a port number — never a token. - // Tokens flow via the /pty-session HttpOnly cookie path. See - // `pty-session-cookie.ts` for the rationale (codex outside-voice - // finding #2: don't reuse this endpoint for shell auth). - terminalPort: readTerminalPort(), }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } - // ─── /pty-session — mint sessionId + lease + attachToken ───────── - // - // 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' }), { - 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' } }); - } - 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, - // 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': 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 @@ -3007,7 +2567,6 @@ export async function start() { xvfb, proxyBridge, startTime, - ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063) }); const server = Bun.serve({ diff --git a/browse/src/sidebar-utils.ts b/browse/src/sidebar-utils.ts deleted file mode 100644 index c5ff201d0..000000000 --- a/browse/src/sidebar-utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared sidebar utilities — extracted for testability. - */ - -/** - * Sanitize a URL from the Chrome extension before embedding in a prompt. - * Only accepts http/https, strips control characters, truncates to 2048 chars. - * Returns null if the URL is invalid or uses a non-http scheme. - */ -export function sanitizeExtensionUrl(url: string | null | undefined): string | null { - if (!url) return null; - try { - const u = new URL(url); - if (u.protocol === 'http:' || u.protocol === 'https:') { - return u.href.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 2048); - } - return null; - } catch { - return null; - } -} diff --git a/browse/src/terminal-agent-control.ts b/browse/src/terminal-agent-control.ts deleted file mode 100644 index 094ba668f..000000000 --- a/browse/src/terminal-agent-control.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * terminal-agent process-control primitives shared by cli.ts spawn site, - * server.ts shutdown teardown, and the v1.44 watchdog/respawn loop. - * - * Why this exists: pre-v1.44 used `pkill -f terminal-agent\.ts`, which - * matches any process whose argv contains the string and would kill - * sibling gstack sessions on the same host. The agent now writes a - * structured `terminal-agent-pid` record (`{pid, gen, startedAt}`) and - * every kill site routes through `killAgentByRecord` here — identity-based, - * no regex. - * - * The `gen` field is a per-boot generation counter. Loopback /internal/* - * calls from the parent server include `X-Browse-Gen` so a slow agent that - * the watchdog respawned around can't accidentally service a stale grant - * from the old generation. - */ -import * as fs from 'fs'; -import * as path from 'path'; -import { safeUnlink, safeKill, isProcessAlive } from './error-handling'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; - -/** - * Locate the terminal-agent script on disk. In dev (cli.ts running via - * `bun run`), it lives next to this file in browse/src. In a compiled - * binary, Bun's --compile bakes the source into the executable and - * exposes it relative to process.execPath. Either path must work or - * the agent can't be spawned at all. - */ -export function resolveTerminalAgentScript(searchHints: { metaDir?: string; execPath?: string } = {}): string | null { - const meta = searchHints.metaDir || __dirname; - const exec = searchHints.execPath || process.execPath; - const candidates = [ - path.resolve(meta, 'terminal-agent.ts'), - path.resolve(path.dirname(exec), '..', 'src', 'terminal-agent.ts'), - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return null; -} - -/** - * Spawn a fresh terminal-agent as a detached child. Handles the standard - * three steps: kill any prior agent recorded at `/terminal-agent-pid`, - * clear the stale record, then `Bun.spawn(['bun', 'run', script], ...)` with - * env wiring. Returns the PID of the new agent on success, null when the - * agent script can't be located. - * - * Used by both the CLI cold-start path (cli.ts) and the v1.44 watchdog in - * server.ts. Centralizing here removes a copy-paste between them and means - * future spawn-env additions (e.g. BROWSE_OWNER_PID for the generation - * counter rollout) land in one place. - */ -export function spawnTerminalAgent(opts: { - stateFile: string; - serverPort: number; - cwd?: string; - /** Optional extra env vars to add to the agent's process env. */ - extraEnv?: Record; - /** Override script lookup for tests. */ - scriptPath?: string; -}): number | null { - const stateDir = path.dirname(opts.stateFile); - const prior = readAgentRecord(stateDir); - if (prior) { - killAgentByRecord(prior, 'SIGTERM'); - clearAgentRecord(stateDir); - } - const script = opts.scriptPath || resolveTerminalAgentScript(); - if (!script || !fs.existsSync(script)) return null; - const proc = (Bun as any).spawn(['bun', 'run', script], { - cwd: opts.cwd || process.cwd(), - env: { - ...process.env, - BROWSE_STATE_FILE: opts.stateFile, - BROWSE_SERVER_PORT: String(opts.serverPort), - ...(opts.extraEnv || {}), - }, - stdio: ['ignore', 'ignore', 'ignore'], - }); - proc.unref?.(); - return proc.pid ?? null; -} - -export interface AgentRecord { - pid: number; - /** Random per-boot identifier. Loopback /internal/* sees X-Browse-Gen: . */ - gen: string; - /** ms since epoch. Reserved for future PID-reuse guards. */ - startedAt: number; -} - -export function agentRecordPath(stateDir: string): string { - return path.join(stateDir, 'terminal-agent-pid'); -} - -/** Read the current record. Returns null on missing/malformed file. */ -export function readAgentRecord(stateDir: string): AgentRecord | null { - try { - const raw = fs.readFileSync(agentRecordPath(stateDir), 'utf-8'); - const j = JSON.parse(raw); - if (typeof j?.pid === 'number' && typeof j?.gen === 'string' && typeof j?.startedAt === 'number') { - return j as AgentRecord; - } - return null; - } catch { - return null; - } -} - -/** Atomic write. Caller must ensure stateDir exists; agent does this at boot. */ -export function writeAgentRecord(stateDir: string, record: AgentRecord): void { - try { mkdirSecure(stateDir); } catch {} - const target = agentRecordPath(stateDir); - const tmp = `${target}.tmp-${process.pid}`; - writeSecureFile(tmp, JSON.stringify(record)); - fs.renameSync(tmp, target); -} - -export function clearAgentRecord(stateDir: string): void { - safeUnlink(agentRecordPath(stateDir)); -} - -/** - * Kill the agent identified by `record`. Signal defaults to SIGTERM (give - * the agent a chance to run its own SIGTERM cleanup). Returns true if a - * signal was actually sent to a live PID; false if the PID was already - * dead (no-op). Never throws — ESRCH is swallowed by safeKill. - * - * Validates liveness BEFORE signaling so a PID-reuse race (the recorded - * PID was reaped and a brand-new unrelated process now holds it) can't - * cause us to kill the wrong process. This is a best-effort defense: - * Linux/macOS don't expose process-start-time cheaply, and the gap - * between record-write and watchdog-tick is small (60s max). - */ -export function killAgentByRecord( - record: AgentRecord, - signal: NodeJS.Signals = 'SIGTERM', -): boolean { - if (!isProcessAlive(record.pid)) return false; - safeKill(record.pid, signal); - return true; -} diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts deleted file mode 100644 index 2e39d99e4..000000000 --- a/browse/src/terminal-agent.ts +++ /dev/null @@ -1,1011 +0,0 @@ -/** - * 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 { writeSecureFile, mkdirSecure } from './file-permissions'; -import { safeUnlink } from './error-handling'; -import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control'; - -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 -/** - * Per-boot generation identifier. Loopback /internal/* callers include - * `X-Browse-Gen: ` so a slow agent the watchdog respawned - * around can't service a stale grant from the prior generation. Absent - * header means "legacy caller" and is accepted (backward compat); a - * present-but-mismatched header returns 409 stale generation. - */ -const CURRENT_GEN = crypto.randomBytes(16).toString('base64url'); - -// In-memory attach-token registry. Parent posts /internal/grant after -// /pty-session; we validate WS upgrades against this map. -// -// v1.44+: each token is bound to a v1.44 sessionId (the stable, non-secret -// identifier from browse/src/pty-session-lease.ts). The token grants ONE -// attach for ONE session — re-attach within the lease window comes through -// /pty-session/reattach, which mints a fresh token for the same sessionId. -// -// Legacy callers can still pass `{token}` without sessionId (the value -// stays null and the WS upgrade still works); those callers don't get -// re-attach because there's no stable identifier to match against. -const validTokens = new Map(); // token → sessionId - -/** - * Reverse index for re-attach lookups: sessionId → live PtySession. - * Populated when a WS first attaches with a known sessionId; cleared when - * the session is disposed or the lease expires. Used by: - * - /ws upgrade: if the incoming attachToken maps to a sessionId that - * already has a live session, REPLACE its ws ref instead of spawning. - * - /internal/restart: enumerate by sessionId, dispose that one session. - * - * Kept separate from the WeakMap so re-attach can find the - * session by id even after the original ws has gone. - */ -const sessionsById = new Map(); - -// 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); -}); - -export interface PtySession { - proc: any | null; // Bun.Subprocess once spawned - cols: number; - rows: number; - cookie: string; - /** - * Current attached websocket. Swapped on re-attach (Commit 3): when a new - * WS upgrade matches this session's sessionId, the old liveWs is gone - * and the new ws takes its place. The PTY on-data callback closes over - * `session`, not the original `ws`, so it always writes to the current - * liveWs (or skips the write when detached and liveWs is null). - */ - liveWs: any | null; - /** - * v1.44+ stable session identifier (from pty-session-lease). Null for - * legacy /internal/grant callers that didn't pass one. Used for - * targeted /internal/restart and Commit 3 re-attach lookups. - */ - sessionId: string | null; - spawned: boolean; - /** - * 25s server-side WS keepalive interval (v1.44+). Set in the WS `open` - * handler, cleared in `close`. We send `{type:"ping",ts}` text frames so - * NAT boxes, proxies, and Chrome's MV3 panel-suspend heuristics see the - * connection as active; the client either replies with `{type:"pong"}` - * or fires its own 25s `{type:"keepalive"}` cycle. Either path keeps - * the underlying TCP from being silently dropped. - */ - pingInterval: ReturnType | null; - /** - * Commit 3 scrollback ring buffer. Each PTY write appends a frame; the - * total byte count is capped at RING_BUFFER_MAX_BYTES with oldest frames - * evicted first. On re-attach, the surviving frames are replayed as a - * single binary frame (prefixed with the v1.44 reset sequence) so the - * user sees their last screen of output. Frame boundaries preserve UTF-8 - * + ANSI-CSI boundaries because each frame is the exact buffer that - * spawnClaude's on-data callback emitted. - */ - ringBuffer: Buffer[]; - ringBufferBytes: number; - /** - * Tracks whether the PTY is currently in xterm alt-screen mode. claude's - * TUI enters alt-screen (CSI ?1049h) during tool calls and exits (CSI - * ?1049l) when returning to the main prompt. On re-attach, the replay - * prelude must re-enter alt-screen if the original PTY left it active, - * otherwise the replay renders against the main screen and the cursor - * + colors end up in the wrong place. - */ - altScreenActive: boolean; - /** - * Detach state machine (Commit 3). When the WS closes for a reason OTHER - * than the v1.44 intentional-restart code (4001), we keep the PtySession - * alive for the detach window (default 60s) so a re-attach within the - * window can resume the same PTY and replay the ring buffer. The timer - * disposes the session if no re-attach arrives in time. - */ - detached: boolean; - detachTimer: ReturnType | null; -} - -/** - * WS keepalive interval. 25s is comfortably under the lowest common NAT - * idle timeout (typically 30-60s) and shorter than Chromium's WebSocket - * dead-peer threshold. Test-overridable via env so the v1.44 e2e tests - * can compress idle-window assertions to <1s without waiting half a - * minute per assertion. - */ -const KEEPALIVE_INTERVAL_MS = parseInt( - process.env.GSTACK_PTY_KEEPALIVE_INTERVAL_MS || '25000', - 10, -); - -/** - * Commit 3 scrollback ring buffer cap. 1 MB is enough for a full screen - * of dense claude output (including a recent tool result), small enough - * that a worst-case 10 detached sessions only cost ~10 MB of RSS. - * Env-overridable so e2e tests can verify eviction without writing 1 MB - * of fixture data per assertion. - */ -const RING_BUFFER_MAX_BYTES = parseInt( - process.env.GSTACK_PTY_RING_BUFFER_BYTES || `${1024 * 1024}`, - 10, -); - -/** - * Commit 3 detach window — how long to keep a session alive after WS - * close (with any code other than 4001 intentional-restart) so a - * re-attach can resume the same PTY. 60s is long enough to cover a - * Chrome MV3 service-worker suspend cycle, a wifi blip, or a brief - * laptop sleep; short enough that genuinely-closed sessions don't - * stack up unbounded. - */ -const DETACH_WINDOW_MS = parseInt( - process.env.GSTACK_PTY_DETACH_WINDOW_MS || '60000', - 10, -); - -/** - * Append a frame to a session's ring buffer, evicting oldest frames if - * the total byte count exceeds RING_BUFFER_MAX_BYTES. Eviction is at - * frame boundaries (one PTY write = one frame), so we never cut a - * multi-byte UTF-8 sequence or a partial ANSI CSI in half — claude's - * on-data callback emits coherent frames. - * - * Side effect: scans the appended chunk for alt-screen enter/exit - * sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive - * so the re-attach prelude knows whether to re-enter alt-screen. - */ -export function appendToRingBuffer(session: PtySession, frame: Buffer): void { - session.ringBuffer.push(frame); - session.ringBufferBytes += frame.length; - while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) { - const evicted = session.ringBuffer.shift()!; - session.ringBufferBytes -= evicted.length; - } - // Alt-screen tracking. Scan for the canonical xterm enter/exit pairs. - // We do this on every append (not just on attach) so the state is - // correct even if many frames have flowed since the last attach. - const ascii = frame.toString('latin1'); // single-byte view is enough — the codes are 7-bit ASCII - // Use lastIndexOf so trailing state wins when both appear in one frame - // (e.g., a quick tool-call open+close inside one render pass). - const enterIdx = ascii.lastIndexOf('\x1b[?1049h'); - const exitIdx = ascii.lastIndexOf('\x1b[?1049l'); - if (enterIdx >= 0 && enterIdx > exitIdx) session.altScreenActive = true; - else if (exitIdx >= 0 && exitIdx > enterIdx) session.altScreenActive = false; -} - -/** - * Build the re-attach replay payload: server-side reset prelude + the - * accumulated ring buffer. The client side writes RIS (`\x1bc`) to xterm - * BEFORE feeding this payload in, so the layout is: - * - * 1. Client: `\x1bc` (RIS — full reset, clears pre-blip xterm content) - * 2. Server: `\x1b[!p` (DECSTR soft reset — re-defaults char attributes) - * 3. Server: optional `\x1b[?1049h` if we were in alt-screen at detach - * 4. Server: ring buffer contents, in append order - * - * The client coordinates the order by waiting for a `{type:"reattach-begin"}` - * text frame before treating the next binary frame as replay. That separation - * is what lets us prepend reset codes without clobbering the live stream - * that resumes immediately after. - */ -export function buildReplayPayload(session: PtySession): Buffer { - const parts: Buffer[] = []; - parts.push(Buffer.from('\x1b[!p')); - if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h')); - for (const frame of session.ringBuffer) parts.push(frame); - return Buffer.concat(parts); -} - -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 { mkdirSecure(stateDir); } 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 { - writeSecureFile(tmp, JSON.stringify(status, null, 2)); - fs.renameSync(tmp, target); - } catch { - safeUnlink(tmp); - } -} - -/** - * System-prompt hint passed to claude via --append-system-prompt. Tells - * claude what tab-awareness affordances exist in this session so it - * doesn't have to discover them by trial. The user can override anything - * here just by saying so — system prompt is a soft hint, not a contract. - * - * Two paths claude has: - * 1. Read live state from /tabs.json + active-tab.json - * (updated continuously by the gstack browser extension). - * 2. Run $B tab, $B tabs, $B tab-each to act on tabs. The - * tab-each helper fans a single command across every open tab and - * returns per-tab results as JSON. - */ -function buildTabAwarenessHint(stateDir: string): string { - const tabsFile = path.join(stateDir, 'tabs.json'); - const activeFile = path.join(stateDir, 'active-tab.json'); - return [ - 'You are running inside the gstack browser sidebar with live access to the user\'s browser tabs.', - '', - 'Tab state files (kept fresh automatically by the extension):', - ` ${tabsFile} — all open tabs (id, url, title, active, pinned)`, - ` ${activeFile} — the currently active tab`, - 'Read these any time the user asks about "tabs", "the current page", or anything multi-tab. Do NOT shell out to $B tabs just to learn what\'s open — read the file.', - '', - 'Tab manipulation commands (via $B):', - ' $B tab — switch to a tab', - ' $B newtab [url] — open a new tab', - ' $B closetab [id] — close a tab (current if no id)', - ' $B tab-each — fan out a command across every tab; returns JSON results', - '', - 'When the user asks for multi-tab work, prefer $B tab-each. Examples:', - ' $B tab-each snapshot -i — grab a snapshot from every tab', - ' $B tab-each text — pull clean text from every tab', - ' $B tab-each title — list every tab\'s title', - '', - 'You\'re in a real terminal with a real PTY — slash commands, /resume, ANSI colors all work as in a normal claude session.', - ].join('\n'); -} - -/** 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', - }; - - // --append-system-prompt is the right injection surface (per `claude --help`): - // it gets appended to the model's system prompt, so claude treats this as - // contextual guidance, not a user message. Don't use a leading PTY write - // for this — that would show up as if the user typed the hint, polluting - // the visible transcript. - const stateDir = path.dirname(STATE_FILE); - const tabHint = buildTabAwarenessHint(stateDir); - - const proc = (Bun as any).spawn([claudePath, '--append-system-prompt', tabHint], { - 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. - */ -/** - * Validate a loopback /internal/* request. Returns null when the request - * is allowed; otherwise returns the Response to send back. Centralizes - * bearer auth + the v1.44 X-Browse-Gen generation check so adding a new - * /internal/* route is a one-liner. - */ -function checkInternalAuth(req: Request): Response | null { - const auth = req.headers.get('authorization'); - if (auth !== `Bearer ${INTERNAL_TOKEN}`) { - return new Response('forbidden', { status: 403 }); - } - const headerGen = req.headers.get('x-browse-gen'); - if (headerGen && headerGen !== CURRENT_GEN) { - return new Response('stale generation', { status: 409 }); - } - return null; -} - -/** - * Wrap a JSON-bodied /internal/* handler with the standard bearer-auth + - * generation-check + json-parse + error-response boilerplate. The handler - * `fn` is called with the parsed body; whatever it returns is JSON-stringified - * into a 200 Response, or the handler can return a Response directly to - * customize status / headers. Throwing from `fn` collapses to a 400 "bad". - * - * Centralizing the dance kills the copy-paste pattern of bearer + gen check - * + req.json().then(...).catch(...) that every /internal/* route needs. - * New routes become a single call to internalHandler. - */ -async function internalHandler( - req: Request, - fn: (body: any) => T | Promise | Response | Promise, -): Promise { - const denied = checkInternalAuth(req); - if (denied) return denied; - let body: any; - try { - body = await req.json(); - } catch { - return new Response('bad', { status: 400 }); - } - try { - const result = await fn(body); - if (result instanceof Response) return result; - if (result === undefined || result === null) return new Response('ok'); - return new Response(JSON.stringify(result), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } catch { - return new Response('bad', { status: 400 }); - } -} - -/** - * Spawn the claude PTY for a session if it hasn't been spawned yet. - * Used by both the legacy binary-frame spawn trigger and the v1.44 explicit - * `{type:"start"}` text-frame trigger. Idempotent on `session.spawned`. - * - * Returns true if claude is now running, false if spawn failed (e.g. claude - * binary not on PATH). On failure, the caller is expected to have already - * surfaced the error to the client (or will via the next frame). - */ -function maybeSpawnPty(ws: any, session: PtySession): boolean { - if (session.spawned) return true; - session.spawned = true; - let leftover = Buffer.alloc(0); - const proc = spawnClaude(session.cols, session.rows, (chunk) => { - const combined = Buffer.concat([leftover, Buffer.from(chunk)]); - // UTF-8 boundary detection (issue #1272). Look back at most 3 bytes - // for the start of an incomplete multibyte sequence and defer it. - let safeEnd = combined.length; - for (let i = combined.length - 1; i >= Math.max(0, combined.length - 3); i--) { - const b = combined[i]; - if ((b & 0x80) === 0) { safeEnd = i + 1; break; } - if ((b & 0xC0) === 0x80) continue; - const expected = (b & 0xE0) === 0xC0 ? 2 : (b & 0xF0) === 0xE0 ? 3 : 4; - safeEnd = (combined.length - i >= expected) ? combined.length : i; - break; - } - const flush = combined.slice(0, safeEnd); - leftover = combined.slice(safeEnd); - if (flush.length) { - // Always record into the ring buffer (Commit 3) so re-attach can - // replay. session.liveWs is what changes across re-attaches — we - // close over `session`, not the original `ws`, so the write always - // goes to whichever ws is currently attached (or is skipped when - // detached and liveWs is null). - appendToRingBuffer(session, flush); - if (session.liveWs) { - try { session.liveWs.sendBinary(flush); } 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 false; - } - session.proc = proc; - proc.exited?.then?.(() => { - try { session.liveWs?.close(1000, 'pty exited'); } catch {} - }); - return true; -} - -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. - // v1.44+: accepts `{token, sessionId?}`. The sessionId binding lets - // the agent route re-attach attempts (same sessionId, fresh token) - // back to the same PtySession. Legacy callers passing just `{token}` - // still work — sessionId becomes null and re-attach is unavailable - // for that grant. - if (url.pathname === '/internal/grant' && req.method === 'POST') { - return internalHandler(req, (body) => { - if (typeof body?.token === 'string' && body.token.length > 16) { - const sid = typeof body?.sessionId === 'string' && body.sessionId.length > 0 - ? body.sessionId - : null; - validTokens.set(body.token, sid); - } - }); - } - - // /internal/revoke — drop a token (called on WS close or bootstrap reload) - if (url.pathname === '/internal/revoke' && req.method === 'POST') { - return internalHandler(req, (body) => { - if (typeof body?.token === 'string') validTokens.delete(body.token); - }); - } - - // /internal/restart — dispose the PtySession for a specific sessionId. - // Scoped to one caller (not enumerate-all). Server.ts /pty-restart - // posts here with the caller's sessionId; we kill ONLY that PTY, - // leaving any other live sidebar tabs untouched. Codex T2 of the - // eng review caught this gap — pre-spec the route would have - // disposed all sessions. - if (url.pathname === '/internal/restart' && req.method === 'POST') { - return internalHandler(req, (body) => { - const sid = typeof body?.sessionId === 'string' ? body.sessionId : null; - if (!sid) return { killed: 0 }; - const session = sessionsById.get(sid); - if (!session) return { killed: 0 }; - // Cancel any pending detach timer before disposal — otherwise it - // would fire later against an already-disposed session. - if (session.detachTimer) { - clearTimeout(session.detachTimer); - session.detachTimer = null; - } - disposeSession(session); - sessionsById.delete(sid); - return { killed: 1 }; - }); - } - - // /internal/healthz — liveness probe used by the v1.44 watchdog. - // Returns this agent's pid + gen + active session count without - // touching claude binary lookup (which can fail for non-process - // reasons and isn't a useful liveness signal). GET — no body to parse, - // so it stays on the bare checkInternalAuth gate. - if (url.pathname === '/internal/healthz' && req.method === 'GET') { - const denied = checkInternalAuth(req); - if (denied) return denied; - return new Response(JSON.stringify({ - pid: process.pid, - gen: CURRENT_GEN, - sessions: validTokens.size, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - - // /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 — required, not optional. - // (2) Token must be in validTokens. We accept the token via two - // transports for compatibility: - // - Sec-WebSocket-Protocol (preferred for browsers — the only - // auth header settable from the browser WebSocket API) - // - Cookie gstack_pty (works for non-browser callers and - // same-port browser callers; doesn't survive the cross-port - // jump from server.ts:34567 to the agent's random port - // when SameSite=Strict is set) - // Either path works; both verify against the same in-memory - // validTokens Set, populated by the parent server's - // authenticated /pty-session → /internal/grant chain. - 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 }); - } - - // Try Sec-WebSocket-Protocol first. Format: a single token, possibly - // with a `gstack-pty.` prefix (which we strip). Browsers send a - // comma-separated list when multiple were requested; we pick the - // first that matches a known token. - const protoHeader = req.headers.get('sec-websocket-protocol') || ''; - let token: string | null = null; - let acceptedProtocol: string | null = null; - for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) { - const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw; - if (validTokens.has(candidate)) { - token = candidate; - acceptedProtocol = raw; - break; - } - } - - // Fallback: Cookie gstack_pty (legacy / non-browser callers). - if (!token) { - const cookieHeader = req.headers.get('cookie') || ''; - for (const part of cookieHeader.split(';')) { - const [name, ...rest] = part.trim().split('='); - if (name === 'gstack_pty') { - const candidate = rest.join('=') || null; - if (candidate && validTokens.has(candidate)) { - token = candidate; - } - break; - } - } - } - - if (!token) { - return new Response('unauthorized', { status: 401 }); - } - - // v1.44+: surface the token's sessionId binding to the upgraded ws. - // open() reads it via ws.data and registers the session in - // sessionsById so /internal/restart and (Commit 3) re-attach - // lookups can find it. - const sessionId = validTokens.get(token) ?? null; - const upgraded = server.upgrade(req, { - data: { cookie: token, sessionId }, - // Echo the protocol back so the browser accepts the upgrade. - // Required when the client sends Sec-WebSocket-Protocol — the - // server MUST select one of the offered protocols, otherwise - // the browser closes the connection immediately. - ...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}), - }); - return upgraded ? undefined : new Response('upgrade failed', { status: 500 }); - } - - return new Response('not found', { status: 404 }); - }, - - websocket: { - /** - * Spawn the claude PTY for `session` if it hasn't been spawned yet. - * Called from both message paths: the legacy binary-frame trigger - * (any keystroke) AND the v1.44 explicit `{type:"start"}` trigger - * (forceRestart sends this on every fresh WS to get an eager prompt - * without requiring the user to type). Idempotent — a second call - * after `spawned: true` is a no-op. - */ - open(ws) { - const sessionId = (ws.data as any)?.sessionId ?? null; - const cookie = (ws.data as any)?.cookie || ''; - - // Commit 3 re-attach: if this sessionId already has a detached - // PtySession in sessionsById, REPLACE its liveWs ref and replay - // the ring buffer. The PTY process is unchanged — claude keeps - // running through the wifi blip / panel-suspend cycle. - if (sessionId) { - const existing = sessionsById.get(sessionId); - if (existing) { - if (existing.detachTimer) { - clearTimeout(existing.detachTimer); - existing.detachTimer = null; - } - existing.detached = false; - existing.liveWs = ws; - existing.cookie = cookie; - // Re-bind the WS-keyed map so resize/close/message handlers - // can still find this session via the new ws. - sessions.set(ws, existing); - // Restart keepalive on the new ws. - if (existing.pingInterval) clearInterval(existing.pingInterval); - existing.pingInterval = setInterval(() => { - try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } catch {} - }, KEEPALIVE_INTERVAL_MS); - // Tell the client to prep its xterm (write RIS) before the - // replay binary arrives. Order matters — the binary frame - // immediately after this text frame IS the replay. - try { ws.send(JSON.stringify({ type: 'reattach-begin', sessionId })); } catch {} - try { ws.sendBinary(buildReplayPayload(existing)); } catch {} - return; - } - } - - const session: PtySession = { - proc: null, - cols: 80, - rows: 24, - cookie, - liveWs: ws, - sessionId, - spawned: false, - pingInterval: null, - ringBuffer: [], - ringBufferBytes: 0, - altScreenActive: false, - detached: false, - detachTimer: null, - }; - session.pingInterval = setInterval(() => { - try { - ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); - } catch { - // ws likely closed mid-tick; close handler clears the interval. - } - }, KEEPALIVE_INTERVAL_MS); - sessions.set(ws, session); - // Index by sessionId for /internal/restart + Commit 3 re-attach. - if (sessionId) sessionsById.set(sessionId, session); - }, - - message(ws, raw) { - let session = sessions.get(ws); - if (!session) { - // Fallback for any path where open() didn't fire (shouldn't happen - // in Bun.serve but keeps the spawn path safe). No keepalive on - // this branch — open() is the supported entry point. - session = { - proc: null, - cols: 80, - rows: 24, - cookie: (ws.data as any)?.cookie || '', - liveWs: ws, - sessionId: (ws.data as any)?.sessionId ?? null, - spawned: false, - pingInterval: null, - ringBuffer: [], - ringBufferBytes: 0, - altScreenActive: false, - detached: false, - detachTimer: null, - }; - sessions.set(ws, session); - if (session.sessionId) sessionsById.set(session.sessionId, session); - } - - // Text frames are control messages: {type: "resize", cols, rows}, - // {type: "tabSwitch", tabId, url, title}, {type: "tabState", ...}, - // or v1.44 keepalive frames: {type: "pong", ts}, {type: "keepalive"}. - // 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; - } - if (msg?.type === 'tabState') { - handleTabState(msg); - return; - } - if (msg?.type === 'pong' || msg?.type === 'keepalive' || msg?.type === 'ping') { - // Keepalive frames — accepted and silently dropped. The mere - // fact that the WS carried this frame is the liveness signal; - // there's no application-level state to update at this layer. - // `ping` is acknowledged here too in case the client (or a - // future agent peer) mirrors our server-side ping shape. - return; - } - if (msg?.type === 'start') { - // v1.44 explicit spawn trigger. forceRestart sends this - // immediately on every fresh WS so claude boots without the - // user having to type a keystroke (pre-v1.44, the lazy-binary - // spawn made restart look stuck until the user typed). No-op - // if already spawned. - maybeSpawnPty(ws, session); - return; - } - // Unknown text frame — ignore. - return; - } - - // Binary input. Lazy-spawn claude on the first byte if `start` - // wasn't sent first. Both paths land in the same maybeSpawnPty - // helper for behavior parity. - if (!session.spawned) { - if (!maybeSpawnPty(ws, session)) return; - } - 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, code, _reason) { - const session = sessions.get(ws); - if (!session) return; - // Always drop the WS-keyed map entry and the per-attach - // attachToken — the attach grant was single-use. - sessions.delete(ws); - if (session.cookie) validTokens.delete(session.cookie); - // Keepalive lives with the WS — every attach starts a fresh one. - if (session.pingInterval) { - clearInterval(session.pingInterval); - session.pingInterval = null; - } - - // Commit 3 detach state machine. If the close was intentional - // (code 4001 = restart, 4404 = no-claude error), dispose - // immediately — there's no value in keeping the PTY alive. - // Otherwise enter the detach window: claude keeps running, the - // ring buffer keeps accumulating, and a re-attach with the same - // sessionId within DETACH_WINDOW_MS picks back up. If the timer - // fires without a re-attach, the session is disposed normally. - // - // Sessions without a sessionId (legacy single-shot grants) can't - // re-attach by definition — fall through to immediate dispose. - const intentional = code === 4001 || code === 4404 || code === 1000; - if (intentional || !session.sessionId) { - disposeSession(session); - if (session.sessionId) sessionsById.delete(session.sessionId); - return; - } - - // Mark detached and start the disposal timer. The session stays - // in sessionsById so the next /ws upgrade with the same - // sessionId can find and reattach to it. - session.detached = true; - session.liveWs = null; - session.detachTimer = setTimeout(() => { - if (!session.detached) return; // re-attached in the meantime - disposeSession(session); - if (session.sessionId) sessionsById.delete(session.sessionId); - }, DETACH_WINDOW_MS); - // setTimeout returns a Bun Timer; unref so the detach window - // doesn't keep the process alive past natural shutdown. - (session.detachTimer as any)?.unref?.(); - }, - }, - }); -} - -/** - * 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. - */ -/** - * Live tab snapshot. Writes /tabs.json (full list) and updates - * /active-tab.json (current active). claude can read these any - * time without invoking $B tabs — saves a round-trip when the model just - * needs to check the landscape before deciding what to do. - */ -function handleTabState(msg: { - active?: { tabId?: number; url?: string; title?: string } | null; - tabs?: Array<{ tabId?: number; url?: string; title?: string; active?: boolean; windowId?: number; pinned?: boolean; audible?: boolean }>; - reason?: string; -}): void { - const stateDir = path.dirname(STATE_FILE); - try { mkdirSecure(stateDir); } catch {} - - // tabs.json — full list - if (Array.isArray(msg.tabs)) { - const payload = { - updatedAt: new Date().toISOString(), - reason: msg.reason || 'unknown', - tabs: msg.tabs.map(t => ({ - tabId: t.tabId ?? null, - url: t.url || '', - title: t.title || '', - active: !!t.active, - windowId: t.windowId ?? null, - pinned: !!t.pinned, - audible: !!t.audible, - })), - }; - const target = path.join(stateDir, 'tabs.json'); - const tmp = path.join(stateDir, `.tmp-tabs-${process.pid}`); - try { - writeSecureFile(tmp, JSON.stringify(payload, null, 2)); - fs.renameSync(tmp, target); - } catch { - safeUnlink(tmp); - } - } - - // active-tab.json — single active tab. Skip chrome-internal pages so - // claude doesn't see chrome:// or chrome-extension:// URLs as - // "current target." - const active = msg.active; - if (active && active.url && !active.url.startsWith('chrome://') && !active.url.startsWith('chrome-extension://')) { - const ctxFile = path.join(stateDir, 'active-tab.json'); - const tmp = path.join(stateDir, `.tmp-tab-${process.pid}`); - try { - writeSecureFile(tmp, JSON.stringify({ - tabId: active.tabId ?? null, - url: active.url, - title: active.title ?? '', - })); - fs.renameSync(tmp, ctxFile); - } catch { - safeUnlink(tmp); - } - } -} - -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 { - writeSecureFile(tmp, JSON.stringify({ - tabId: msg.tabId ?? null, - url, - title: msg.title ?? '', - })); - 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 { mkdirSecure(dir); } catch {} - const tmp = `${PORT_FILE}.tmp-${process.pid}`; - writeSecureFile(tmp, String(port)); - fs.renameSync(tmp, PORT_FILE); - - // Write identity-based agent record (pid + per-boot gen). Replaces the - // v1.43- `pkill -f terminal-agent\.ts` regex teardown that could kill - // sibling gstack sessions. Callers (cli.ts spawn site, server.ts - // shutdown, the v1.44 watchdog) now route through killAgentByRecord in - // terminal-agent-control.ts. - writeAgentRecord(dir, { pid: process.pid, gen: CURRENT_GEN, startedAt: Date.now() }); - - // 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} gen=${CURRENT_GEN}`); - - // Cleanup port file + agent record on exit. - const cleanup = () => { - safeUnlink(PORT_FILE); - clearAgentRecord(dir); - 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 { - mkdirSecure(path.dirname(INTERNAL_TOKEN_FILE)); - writeSecureFile(INTERNAL_TOKEN_FILE, INTERNAL_TOKEN); -} catch {} - -main(); diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index 9ee1a5f29..2237461f7 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -48,9 +48,9 @@ describe('Dual-listener surface types', () => { }); describe('Tunnel path allowlist', () => { - test('TUNNEL_PATHS is a closed set containing exactly /connect, /command, /sidebar-chat', () => { + test('TUNNEL_PATHS is a closed set containing exactly /connect, /command', () => { const paths = extractSetContents(SERVER_SRC, 'TUNNEL_PATHS'); - expect(paths).toEqual(new Set(['/connect', '/command', '/sidebar-chat'])); + expect(paths).toEqual(new Set(['/connect', '/command'])); }); test('TUNNEL_PATHS does NOT contain bootstrap or admin paths', () => { diff --git a/browse/test/pty-session-lease.test.ts b/browse/test/pty-session-lease.test.ts deleted file mode 100644 index a1053d38e..000000000 --- a/browse/test/pty-session-lease.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, test, expect, beforeEach } from 'bun:test'; - -// pty-session-lease registers a sessionId space distinct from the pre-v1.44 -// attach-token space (browse/src/pty-session-cookie.ts). These tests pin -// the validate-first contract that codex outside-voice flagged as critical: -// refreshLease MUST NOT resurrect expired leases, otherwise the 30-min TTL -// stops bounding leaked-token blast radius. - -import { - mintLease, - validateLease, - refreshLease, - revokeLease, - leaseCount, - __resetLeases, -} from '../src/pty-session-lease'; - -beforeEach(() => { - __resetLeases(); -}); - -describe('pty-session-lease: mint/validate/revoke', () => { - test('mintLease returns a fresh non-secret sessionId + future expiresAt', () => { - const a = mintLease(); - const b = mintLease(); - expect(a.sessionId).toBeTruthy(); - expect(b.sessionId).toBeTruthy(); - expect(a.sessionId).not.toBe(b.sessionId); - expect(a.expiresAt).toBeGreaterThan(Date.now()); - // base64url alphabet: characters in [A-Za-z0-9_-]. - expect(a.sessionId).toMatch(/^[A-Za-z0-9_-]+$/); - expect(leaseCount()).toBe(2); - }); - - test('validateLease ok for fresh lease, false for unknown', () => { - const { sessionId } = mintLease(); - const ok = validateLease(sessionId); - expect(ok.ok).toBe(true); - if (ok.ok) expect(ok.expiresAt).toBeGreaterThan(Date.now()); - expect(validateLease('not-a-real-session-id').ok).toBe(false); - expect(validateLease(null).ok).toBe(false); - expect(validateLease(undefined).ok).toBe(false); - }); - - test('revokeLease removes the lease; subsequent validate returns false', () => { - const { sessionId } = mintLease(); - expect(validateLease(sessionId).ok).toBe(true); - revokeLease(sessionId); - expect(validateLease(sessionId).ok).toBe(false); - expect(leaseCount()).toBe(0); - }); - - test('revokeLease tolerates unknown sessionId without throwing', () => { - expect(() => revokeLease('phantom')).not.toThrow(); - expect(() => revokeLease(null)).not.toThrow(); - }); -}); - -describe('pty-session-lease: refresh contract (validate-first)', () => { - test('refreshLease extends expiresAt for a valid lease', () => { - const { sessionId, expiresAt: initial } = mintLease(); - // Sleep micro-tick — Date.now() is ms-grain so a synchronous extend - // may not move the integer. Use a tight async wait instead. - return new Promise((resolve) => { - setTimeout(() => { - const r = refreshLease(sessionId); - expect(r.ok).toBe(true); - if (r.ok) expect(r.expiresAt).toBeGreaterThan(initial); - resolve(); - }, 5); - }); - }); - - test('refreshLease rejects unknown sessionId (validate-first invariant)', () => { - const r = refreshLease('never-minted'); - expect(r.ok).toBe(false); - }); - - test('refreshLease never resurrects an expired lease', async () => { - // Force TTL down to 5ms for this assertion by minting + waiting past expiry. - // Lease internals use Date.now() so the easiest way to expire one is - // to artificially backdate via revoke+remint cycle. Simpler: mint, then - // wait for the registry's own expiry check to trip. - // - // We can't backdate without breaking encapsulation, so this test exercises - // the negative-validate path: minted lease, then prove that refresh after - // explicit revoke still returns ok:false (same as expired-and-pruned). - const { sessionId } = mintLease(); - revokeLease(sessionId); - const r = refreshLease(sessionId); - expect(r.ok).toBe(false); - }); - - test('refreshLease tolerates null / undefined sessionId', () => { - expect(refreshLease(null).ok).toBe(false); - expect(refreshLease(undefined).ok).toBe(false); - }); -}); diff --git a/browse/test/security-sidepanel-dom.test.ts b/browse/test/security-sidepanel-dom.test.ts deleted file mode 100644 index 38f724de9..000000000 --- a/browse/test/security-sidepanel-dom.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Real-Chromium regression coverage for the sidepanel's current security UI. - * - * The classifier-backed chat queue was removed when the primary surface - * became a terminal PTY. Until classifier status is wired to that surface, - * the honest contract is deliberately negative: - * - * - /health.security.status must not light the hidden SEC shield. - * - retired /sidebar-chat security_event data must not render a banner or - * leak attacker-controlled text into the terminal surface. - * - * Every HTTP, SSE, WebSocket, and beacon primitive is replaced before the - * sidepanel scripts load, so this test never reaches a real browse server. - */ - -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { chromium, type Browser, type Page } from 'playwright'; - -const EXTENSION_DIR = path.resolve(import.meta.dir, '..', '..', 'extension'); -const SIDEPANEL_URL = `file://${EXTENSION_DIR}/sidepanel.html`; - -const CHROMIUM_AVAILABLE = (() => { - try { - const executable = chromium.executablePath(); - return Boolean(executable && fs.existsSync(executable)); - } catch { - return false; - } -})(); - -type Scenario = { - healthSecurity: { - status: 'protected' | 'degraded' | 'inactive'; - layers?: Record; - }; - securityEntries?: unknown[]; -}; - -async function installStubsBeforeLoad(page: Page, scenario: Scenario): Promise { - await page.addInitScript((params: Scenario) => { - const requests: Array<{ url: string; method: string }> = []; - (window as any).__gstackTestRequests = requests; - - (window as any).chrome = { - runtime: { - sendMessage: (_request: unknown, callback?: (value: unknown) => void) => { - // Omit a token so sidepanel.js exercises the direct /health - // bootstrap path whose security payload is under test. - const payload = { connected: true, port: 34567 }; - if (typeof callback === 'function') { - setTimeout(() => callback(payload), 0); - return undefined; - } - return Promise.resolve(payload); - }, - lastError: null, - onMessage: { addListener: () => {} }, - }, - tabs: { - query: (_query: unknown, callback: (tabs: unknown[]) => void) => - setTimeout(() => callback([{ id: 1, url: 'https://example.com' }]), 0), - onActivated: { addListener: () => {} }, - onUpdated: { addListener: () => {} }, - }, - }; - - (window as any).EventSource = class StubEventSource { - static CONNECTING = 0; - static OPEN = 1; - static CLOSED = 2; - readyState = 1; - - constructor(url: string) { - requests.push({ url: String(url), method: 'EVENTSOURCE' }); - } - - addEventListener() {} - close() { this.readyState = 2; } - }; - - (window as any).WebSocket = class StubWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - readyState = 0; - - constructor(url: string) { - requests.push({ url: String(url), method: 'WEBSOCKET' }); - } - - addEventListener() {} - send() {} - close() { this.readyState = 3; } - }; - - Object.defineProperty(navigator, 'sendBeacon', { - configurable: true, - value: (url: string) => { - requests.push({ url: String(url), method: 'BEACON' }); - return true; - }, - }); - - window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - requests.push({ url, method: init?.method ?? 'GET' }); - - if (url.endsWith('/health')) { - return new Response(JSON.stringify({ - status: 'healthy', - token: 'test-token', - AUTH_TOKEN: 'test-token', - mode: 'headed', - agent: { status: 'idle', runningFor: null, queueLength: 0 }, - session: null, - security: params.healthSecurity, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.endsWith('/sse-session')) { - return new Response(null, { status: 204 }); - } - if (url.endsWith('/memory')) { - return new Response(JSON.stringify({ bunServer: { rss: 0 }, tabs: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url.endsWith('/pty-session')) { - // Keep the terminal bootstrap deterministic and prevent a WebSocket - // attempt; this test concerns the pre-session terminal surface. - return new Response('terminal disabled in DOM test', { status: 503 }); - } - if (url.includes('/sidebar-chat')) { - return new Response(JSON.stringify({ - entries: params.securityEntries ?? [], - total: (params.securityEntries ?? []).length, - agentStatus: 'idle', - security: params.healthSecurity, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } - if (url.endsWith('/refs')) { - return new Response(JSON.stringify({ refs: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Fail closed inside the stub rather than falling through to the real - // network. Recording the URL above keeps unexpected bootstrap calls - // diagnosable in assertion output. - return new Response(JSON.stringify({ error: 'unstubbed test endpoint' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); - }; - }, scenario); -} - -async function openStubbedSidepanel( - scenario: Scenario, - assertion: (page: Page) => Promise, -): Promise { - const context = await browser!.newContext(); - try { - const page = await context.newPage(); - await installStubsBeforeLoad(page, scenario); - await page.goto(SIDEPANEL_URL); - await page.waitForFunction(() => - (window as any).gstackAuthToken === 'test-token' && - document.getElementById('footer-dot')?.classList.contains('connected'), - ); - await assertion(page); - } finally { - await context.close(); - } -} - -let browser: Browser | null = null; - -beforeAll(async () => { - if (!CHROMIUM_AVAILABLE) return; - browser = await chromium.launch({ headless: true }); -}, 30_000); - -afterAll(async () => { - if (!browser) return; - try { - await browser.close(); - } catch {} - browser = null; -}); - -describe('sidepanel security DOM', () => { - test.skipIf(!CHROMIUM_AVAILABLE)( - 'protected health metadata does not expose an unwired SEC claim', - async () => { - await openStubbedSidepanel({ - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - }, async (page) => { - const shield = page.locator('#security-shield'); - expect(await shield.count()).toBe(1); - expect(await shield.isVisible()).toBe(false); - expect(await shield.getAttribute('data-status')).toBeNull(); - expect(await shield.getAttribute('aria-label')).toBe('Security status: unknown'); - - const visibleText = await page.locator('body').innerText(); - expect(visibleText).not.toContain('SEC'); - expect(visibleText.toLowerCase()).not.toContain('protected'); - - const requests = await page.evaluate(() => (window as any).__gstackTestRequests); - expect(requests.some((request: { url: string }) => request.url.endsWith('/health'))).toBe(true); - expect(requests.some((request: { url: string }) => request.url.endsWith('/sse-session'))).toBe(true); - }); - }, - 15_000, - ); - - test.skipIf(!CHROMIUM_AVAILABLE)( - 'retired security_event data is neither polled nor rendered into the terminal', - async () => { - const attackerMarker = 'ATTACKER-CONTROLLED-TERMINAL-MARKER'; - const attackerDomain = 'retired-chat.attacker.example'; - await openStubbedSidepanel({ - healthSecurity: { - status: 'protected', - layers: { testsavant: 'ok', transcript: 'ok', canary: 'ok' }, - }, - securityEntries: [{ - id: 1, - ts: '2026-04-20T00:00:00Z', - role: 'agent', - type: 'security_event', - verdict: 'block', - reason: attackerMarker, - layer: 'canary', - confidence: 1, - domain: attackerDomain, - }], - }, async (page) => { - // Let immediate connection work and the first memory poll settle; - // neither may reintroduce the retired chat polling path. - await page.waitForTimeout(650); - - const requests = await page.evaluate(() => (window as any).__gstackTestRequests); - expect(requests.some((request: { url: string }) => request.url.includes('/sidebar-chat'))).toBe(false); - expect(requests.some((request: { url: string }) => request.url.endsWith('/memory'))).toBe(true); - expect(requests.some((request: { url: string }) => request.url.startsWith('https://'))).toBe(false); - - expect(await page.locator('#security-banner').count()).toBe(0); - expect(await page.locator('.security-banner').count()).toBe(0); - const terminalText = await page.locator('#tab-terminal').innerText(); - expect(terminalText).not.toContain(attackerMarker); - expect(terminalText).not.toContain(attackerDomain); - expect(await page.locator('#security-shield').isVisible()).toBe(false); - }); - }, - 15_000, - ); -}); diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2469a121b..24118e68b 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -314,7 +314,7 @@ describe('Server auth security', () => { // Regression: connect command crashed with "domains is not defined" because // a stray `domains,` variable was in the status fetch body (cli.ts:852). test('connect command status fetch body has no undefined variable references', () => { - const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started'); + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed'); // The status fetch should use a clean JSON body expect(connectBlock).toContain("command: 'status'"); // Must NOT contain a bare `domains` reference in the fetch body @@ -341,7 +341,7 @@ describe('Server auth security', () => { // assigned via object-literal syntax (`BROWSE_PARENT_PID: '0'`) // inside the `const serverEnv: Record = { ... }` // declaration. Assert both pieces appear in the connect block. - const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started'); + const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Connect failed'); expect(connectBlock).toContain("const serverEnv"); expect(connectBlock).toContain("BROWSE_PARENT_PID: '0'"); }); diff --git a/browse/test/server-embedder-terminal-port.test.ts b/browse/test/server-embedder-terminal-port.test.ts deleted file mode 100644 index f24ee3510..000000000 --- a/browse/test/server-embedder-terminal-port.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; -import { - buildFetchHandler, - __resetShuttingDown, - type ServerConfig, -} from '../src/server'; -import { __resetRegistry } from '../src/token-registry'; -import { BrowserManager } from '../src/browser-manager'; -import { resolveConfig } from '../src/config'; - -// Tests for the v1.41+ ownsTerminalAgent flag. -// -// Embedders (gbrowser phoenix overlay) that run their own PTY server and write -// terminal-port / terminal-internal-token / terminal-agent-pid themselves were -// getting those files clobbered by gstack's shutdown(). The flag (default true) -// gates four side effects (v1.44+): -// 1. identity-based kill of the PID in /terminal-agent-pid -// 2. unlink terminal-port -// 3. unlink terminal-internal-token -// 4. unlink terminal-agent-pid -// False = embedder owns them, gstack stays hands-off. -// -// Pre-v1.44 used `pkill -f terminal-agent\.ts` which matched sibling gstack -// sessions on the same host — see browse/src/terminal-agent-control.ts header. -// -// CRITICAL: each test stubs process.exit (so shutdown's exit doesn't kill -// the test runner). The PID in the test agent-record is a guaranteed-dead -// PID (1 = init / launchd — exists but cannot be killed by an unprivileged -// process, so safeKill returns ESRCH-equivalent without affecting anything). -// Use isProcessAlive's false branch by also testing with a PID that does -// not exist (negative PID rejected by the OS). - -const stateDir = resolveConfig().stateDir; -const PORT_FILE = path.join(stateDir, 'terminal-port'); -const TOKEN_FILE = path.join(stateDir, 'terminal-internal-token'); -const AGENT_RECORD_FILE = path.join(stateDir, 'terminal-agent-pid'); -const SENTINEL_PORT = 'sentinel-port-65432'; -const SENTINEL_TOKEN = 'sentinel-token-abcdef1234567890'; -// PID 2^31-1 is the Linux PID_MAX_LIMIT; macOS uses 99998. Either way, no -// real process will ever hold this PID on a developer machine. isProcessAlive -// returns false → killAgentByRecord no-ops without sending any signal. -const SENTINEL_DEAD_PID = 2147483646; - -function makeMinimalConfig(overrides: Partial = {}): ServerConfig { - const token = 'embedder-test-' + crypto.randomBytes(16).toString('hex'); - return { - authToken: token, - browsePort: 34568, - idleTimeoutMs: 1_800_000, - config: resolveConfig(), - browserManager: new BrowserManager(), - startTime: Date.now(), - ...overrides, - }; -} - -function writeSentinels(): void { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(PORT_FILE, SENTINEL_PORT); - fs.writeFileSync(TOKEN_FILE, SENTINEL_TOKEN); - fs.writeFileSync( - AGENT_RECORD_FILE, - JSON.stringify({ pid: SENTINEL_DEAD_PID, gen: 'sentinel-gen', startedAt: Date.now() }), - ); -} - -function readIfExists(p: string): string | null { - try { return fs.readFileSync(p, 'utf-8'); } catch { return null; } -} - -/** - * Stubs process.exit so shutdown()'s process.exit(0) throws an __exit:N - * marker the test can swallow instead of killing the runner. Also stubs - * process.kill so an accidental kill (regression in killAgentByRecord - * that bypassed isProcessAlive) cannot reach a real PID on the developer - * machine. Returns the captured kill calls so tests can assert kill - * scope. - */ -async function withStubs( - cb: (killCalls: Array<[number, NodeJS.Signals | number]>) => Promise -): Promise> { - const origExit = process.exit; - const origKill = process.kill; - const killCalls: Array<[number, NodeJS.Signals | number]> = []; - (process as any).exit = ((code: number) => { - throw new Error(`__exit:${code}`); - }) as any; - (process as any).kill = ((pid: number, signal: NodeJS.Signals | number) => { - killCalls.push([pid, signal ?? 'SIGTERM']); - // signal 0 is a liveness probe — keep the existing 'process is dead' - // semantics so isProcessAlive(SENTINEL_DEAD_PID) returns false. - if (signal === 0) { - const err: any = new Error('No such process'); - err.code = 'ESRCH'; - throw err; - } - return true; - }) as any; - try { - await cb(killCalls); - } finally { - (process as any).exit = origExit; - (process as any).kill = origKill; - } - return killCalls; -} - -async function runShutdown(handle: { shutdown: (code?: number) => Promise }): Promise { - try { - await handle.shutdown(0); - } catch (err: any) { - if (typeof err?.message !== 'string' || !err.message.startsWith('__exit:')) throw err; - } -} - -// Filter out the signal=0 liveness probes; only count actual termination signals. -function terminationCalls( - calls: Array<[number, NodeJS.Signals | number]>, -): Array<[number, NodeJS.Signals | number]> { - return calls.filter(([, sig]) => sig !== 0); -} - -describe('buildFetchHandler ownsTerminalAgent gate', () => { - // shutdown() reads `path.dirname(config.stateFile)` from module-level config - // (composition gap — see TODOS T9). So unlinks target the real state dir, - // not a per-test temp dir. If a real gstack daemon is running on this host, - // its terminal-port + terminal-internal-token + terminal-agent-pid live - // where this test writes. Save + restore real-daemon file contents around - // the whole suite so the test never clobbers a developer's running session. - let realPortBackup: string | null = null; - let realTokenBackup: string | null = null; - let realAgentRecordBackup: string | null = null; - - beforeAll(() => { - realPortBackup = readIfExists(PORT_FILE); - realTokenBackup = readIfExists(TOKEN_FILE); - realAgentRecordBackup = readIfExists(AGENT_RECORD_FILE); - }); - - afterAll(() => { - if (realPortBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(PORT_FILE, realPortBackup); - } else { - try { fs.unlinkSync(PORT_FILE); } catch {} - } - if (realTokenBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(TOKEN_FILE, realTokenBackup); - } else { - try { fs.unlinkSync(TOKEN_FILE); } catch {} - } - if (realAgentRecordBackup !== null) { - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(AGENT_RECORD_FILE, realAgentRecordBackup); - } else { - try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {} - } - }); - - beforeEach(() => { - __resetRegistry(); - __resetShuttingDown(); - // Clean any leftover sentinels from a prior failed run so the "preserved" - // assertion can't pass spuriously off a stale file. - try { fs.unlinkSync(PORT_FILE); } catch {} - try { fs.unlinkSync(TOKEN_FILE); } catch {} - try { fs.unlinkSync(AGENT_RECORD_FILE); } catch {} - }); - - test('1. ownsTerminalAgent:false preserves all three files and sends no signal', async () => { - writeSentinels(); - const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: false })); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBe(SENTINEL_PORT); - expect(readIfExists(TOKEN_FILE)).toBe(SENTINEL_TOKEN); - expect(readIfExists(AGENT_RECORD_FILE)).not.toBeNull(); - expect(terminationCalls(calls).length).toBe(0); - }); - - test('2. ownsTerminalAgent:true deletes all three files; identity-based kill probes the recorded PID', async () => { - writeSentinels(); - const handle = buildFetchHandler(makeMinimalConfig({ ownsTerminalAgent: true })); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBeNull(); - expect(readIfExists(TOKEN_FILE)).toBeNull(); - expect(readIfExists(AGENT_RECORD_FILE)).toBeNull(); - // isProcessAlive sends signal 0; PID is the sentinel-dead PID, so the - // probe returns false and no SIGTERM is sent. - const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0); - expect(probes.length).toBeGreaterThan(0); - expect(terminationCalls(calls).length).toBe(0); - }); - - test('3. ownsTerminalAgent unset defaults to true (deletes all three; probes recorded PID)', async () => { - writeSentinels(); - // Note: no ownsTerminalAgent in the overrides — uses the `?? true` default. - const handle = buildFetchHandler(makeMinimalConfig()); - const calls = await withStubs(async () => { - await runShutdown(handle); - }); - expect(readIfExists(PORT_FILE)).toBeNull(); - expect(readIfExists(TOKEN_FILE)).toBeNull(); - expect(readIfExists(AGENT_RECORD_FILE)).toBeNull(); - const probes = calls.filter(([pid, sig]) => pid === SENTINEL_DEAD_PID && sig === 0); - expect(probes.length).toBeGreaterThan(0); - }); - - test('4. CLI start() call site passes ownsTerminalAgent: true literally (static grep)', () => { - // Resolves browse/src/server.ts relative to this test file so the test - // works regardless of cwd. import.meta.url is the test file's URL. - const serverTsPath = path.resolve( - new URL(import.meta.url).pathname, - '..', - '..', - 'src', - 'server.ts', - ); - const source = fs.readFileSync(serverTsPath, 'utf-8'); - // Match the call site inside start()'s buildFetchHandler({...}) literal. - // The pattern looks for the trailing comma and trailing context so the - // match cannot be satisfied by the JSDoc reference earlier in the file. - expect(source).toMatch(/ownsTerminalAgent:\s*true,\s*\/\/\s*CLI spawns terminal-agent\.ts/); - }); -}); diff --git a/browse/test/server-pty-lease-routes.test.ts b/browse/test/server-pty-lease-routes.test.ts deleted file mode 100644 index 2c1261883..000000000 --- a/browse/test/server-pty-lease-routes.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -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); -} diff --git a/browse/test/sidebar-integration.test.ts b/browse/test/sidebar-integration.test.ts deleted file mode 100644 index 534609734..000000000 --- a/browse/test/sidebar-integration.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * HTTP regression for the terminal-first sidepanel architecture. - * - * The legacy one-shot sidebar-agent/chat queue was removed in v1.44. These - * routes must stay unavailable: silently reviving one would recreate a second - * agent lifecycle and its retired prompt/security surface. Current terminal, - * activity, and browser routes have their own focused integration suites. - */ - -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { spawn, type Subprocess } from 'bun'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -let serverProc: Subprocess | null = null; -let serverPort = 0; -let authToken = ''; -let tmpDir = ''; -let stateFile = ''; -let retiredQueueFile = ''; - -async function api(pathname: string, opts: RequestInit & { noAuth?: boolean } = {}): Promise { - const { noAuth, ...fetchOpts } = opts; - const headers: Record = { - 'Content-Type': 'application/json', - ...(fetchOpts.headers as Record || {}), - }; - if (!noAuth && !headers.Authorization && authToken) { - headers.Authorization = `Bearer ${authToken}`; - } - return fetch(`http://127.0.0.1:${serverPort}${pathname}`, { ...fetchOpts, headers }); -} - -beforeAll(async () => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sidebar-retired-routes-')); - stateFile = path.join(tmpDir, 'browse.json'); - retiredQueueFile = path.join(tmpDir, 'sidebar-queue.jsonl'); - - const serverScript = path.resolve(import.meta.dir, '..', 'src', 'server.ts'); - serverProc = spawn(['bun', 'run', serverScript], { - env: { - ...process.env, - BROWSE_STATE_FILE: stateFile, - BROWSE_HEADLESS_SKIP: '1', - BROWSE_PORT: '0', - SIDEBAR_QUEUE_PATH: retiredQueueFile, - BROWSE_IDLE_TIMEOUT: '300', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (fs.existsSync(stateFile)) { - try { - const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - if (state.port && state.token) { - serverPort = state.port; - authToken = state.token; - break; - } - } catch {} - } - await Bun.sleep(100); - } - if (!serverPort) throw new Error('Server did not start in time'); -}, 20_000); - -afterAll(() => { - if (serverProc) { - try { serverProc.kill(); } catch {} - } - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} -}); - -const RETIRED_ROUTES: Array<[string, string]> = [ - ['POST', '/sidebar-command'], - ['POST', '/sidebar-agent/event'], - ['POST', '/sidebar-agent/kill'], - ['GET', '/sidebar-session'], - ['POST', '/sidebar-session/new'], - ['GET', '/sidebar-chat?after=0'], - ['POST', '/sidebar-chat/clear'], -]; - -describe('retired sidebar-agent HTTP surface', () => { - test('still applies authentication before disclosing route availability', async () => { - const response = await api('/sidebar-command', { - method: 'POST', - noAuth: true, - body: JSON.stringify({ message: 'test' }), - }); - expect(response.status).toBe(401); - }); - - test('every retired route is absent for an authenticated caller', async () => { - for (const [method, route] of RETIRED_ROUTES) { - const response = await api(route, { - method, - body: method === 'GET' ? undefined : JSON.stringify({ message: 'test', type: 'text' }), - }); - expect(response.status).toBe(404); - } - }); - - test('probing retired routes never creates the old queue file', async () => { - expect(fs.existsSync(retiredQueueFile)).toBe(false); - await api('/sidebar-command', { - method: 'POST', - body: JSON.stringify({ message: 'must not queue' }), - }); - expect(fs.existsSync(retiredQueueFile)).toBe(false); - }); - - test('the current authenticated health surface remains available', async () => { - const response = await api('/health'); - expect(response.status).toBe(200); - const payload = await response.json() as { status?: string }; - expect(['healthy', 'unhealthy']).toContain(payload.status); - }); -}); diff --git a/browse/test/sidebar-security.test.ts b/browse/test/sidebar-security.test.ts deleted file mode 100644 index f371317bb..000000000 --- a/browse/test/sidebar-security.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Current terminal-sidepanel security boundary. - * - * Detailed PTY lifecycle behavior has dedicated tests. These source contracts - * instead pin the cross-process handoff: the extension trades the daemon root - * token for a session-scoped attach token, and only the loopback terminal agent - * accepts that token from a Chrome extension origin. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const ROOT = path.resolve(import.meta.dir, '..', '..'); -const TERMINAL_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'terminal-agent.ts'); -const SERVER_PATH = path.join(ROOT, 'browse', 'src', 'server.ts'); -const LEGACY_AGENT_PATH = path.join(ROOT, 'browse', 'src', 'sidebar-agent.ts'); -const TERMINAL_CLIENT_PATH = path.join(ROOT, 'extension', 'sidepanel-terminal.js'); -const SIDEPANEL_PATH = path.join(ROOT, 'extension', 'sidepanel.js'); -const BACKGROUND_PATH = path.join(ROOT, 'extension', 'background.js'); - -const TERMINAL_AGENT_SRC = fs.readFileSync(TERMINAL_AGENT_PATH, 'utf8'); -const SERVER_SRC = fs.readFileSync(SERVER_PATH, 'utf8'); -const TERMINAL_CLIENT_SRC = fs.readFileSync(TERMINAL_CLIENT_PATH, 'utf8'); -const SIDEPANEL_SRC = fs.readFileSync(SIDEPANEL_PATH, 'utf8'); -const BACKGROUND_SRC = fs.readFileSync(BACKGROUND_PATH, 'utf8'); - -function sliceBetween(source: string, startMarker: string, endMarker: string): string { - const start = source.indexOf(startMarker); - if (start === -1) throw new Error(`Missing source marker: ${startMarker}`); - const end = source.indexOf(endMarker, start + startMarker.length); - if (end === -1) throw new Error(`Missing source marker: ${endMarker}`); - return source.slice(start, end); -} - -describe('terminal sidepanel security boundary', () => { - test('PTY transport stays on loopback and sends attach auth outside the URL', () => { - expect(TERMINAL_AGENT_SRC).toContain("hostname: '127.0.0.1'"); - expect(TERMINAL_AGENT_SRC).not.toContain("hostname: '0.0.0.0'"); - - const socketCalls = [...TERMINAL_CLIENT_SRC.matchAll(/new WebSocket\(([\s\S]*?)\);/g)] - .map((match) => match[1]); - expect(socketCalls.length).toBeGreaterThan(0); - for (const call of socketCalls) { - expect(call).toContain('ws://127.0.0.1:${terminalPort}/ws'); - expect(call).toContain('gstack-pty.${'); - expect(call).not.toContain('/ws?'); - expect(call).not.toContain('authToken'); - } - }); - - test('WebSocket upgrade requires extension Origin plus an in-memory session token', () => { - expect(TERMINAL_AGENT_SRC).toContain('const validTokens = new Map()'); - const wsRoute = sliceBetween( - TERMINAL_AGENT_SRC, - "if (url.pathname === '/ws')", - "return new Response('not found'", - ); - - const originGate = wsRoute.indexOf("origin.startsWith('chrome-extension://')"); - const tokenGate = wsRoute.indexOf('validTokens.has(candidate)'); - const upgrade = wsRoute.indexOf('server.upgrade(req'); - expect(originGate).toBeGreaterThan(-1); - expect(tokenGate).toBeGreaterThan(originGate); - expect(upgrade).toBeGreaterThan(tokenGate); - expect(wsRoute).toContain('forbidden origin'); - expect(wsRoute).toContain("req.headers.get('sec-websocket-protocol')"); - expect(wsRoute).not.toContain("searchParams.get('token')"); - }); - - test('/pty-session authenticates the daemon token then mints a session-scoped attach', () => { - const route = sliceBetween( - SERVER_SRC, - "if (url.pathname === '/pty-session' && req.method === 'POST')", - "if (url.pathname === '/pty-session/reattach'", - ); - expect(route.indexOf('validateAuth(req)')).toBeLessThan(route.indexOf('mintLease()')); - expect(route).toContain('grantPtyToken(minted.token, lease.sessionId)'); - expect(route).toContain('sessionId: lease.sessionId'); - expect(route).toContain('attachToken: minted.token'); - - const clientMint = sliceBetween( - TERMINAL_CLIENT_SRC, - 'async function mintSession()', - 'function startReattachLoop', - ); - expect(clientMint).toContain('/pty-session`'); - expect(clientMint).toContain("'Authorization': `Bearer ${token}`"); - expect(clientMint).not.toContain('?token='); - }); - - test('/pty-dispose authenticates and tears down only the named session', () => { - const route = sliceBetween( - SERVER_SRC, - "if (url.pathname === '/pty-dispose'", - "if (url.pathname === '/internal/lease-refresh'", - ); - expect(route).toContain('authTokenFromBody === authToken'); - expect(route).toContain("body?.sessionId === 'string'"); - expect(route).toContain('restartPtySession(sessionId)'); - expect(route).toContain('revokeLease(sessionId)'); - - const pagehide = SIDEPANEL_SRC.slice(SIDEPANEL_SRC.indexOf("addEventListener('pagehide'")); - expect(TERMINAL_CLIENT_SRC).toContain('window.gstackPtySession = currentSessionId'); - expect(pagehide).toContain('JSON.stringify({ sessionId, authToken })'); - expect(pagehide).toContain('/pty-dispose`'); - expect(pagehide).not.toContain('/pty-dispose?'); - }); - - test('background token bootstrap rejects foreign and content-script requesters', () => { - const listener = sliceBetween( - BACKGROUND_SRC, - 'chrome.runtime.onMessage.addListener((msg, sender, sendResponse)', - "if (msg.type === 'fetchRefs')", - ); - expect(listener).toContain('sender.id !== chrome.runtime.id'); - - const getToken = listener.slice(listener.indexOf("if (msg.type === 'getToken')")); - expect(getToken).toContain('if (sender.tab)'); - expect(getToken).toContain('sendResponse({ token: null })'); - expect(getToken).toContain('sendResponse({ token: authToken })'); - }); - - test('interactive prompt path replaces the retired sidebar agent and routes', () => { - expect(fs.existsSync(LEGACY_AGENT_PATH)).toBe(false); - expect(SERVER_SRC).not.toMatch(/url\.pathname\s*===\s*['"]\/sidebar-/); - expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(\s*['"]\/sidebar-/); - expect(SERVER_SRC).toContain('chatEnabled: false'); - - const spawn = sliceBetween(TERMINAL_AGENT_SRC, 'function spawnClaude', '/** Cleanup a PTY session'); - expect(spawn).toContain("[claudePath, '--append-system-prompt', tabHint]"); - expect(spawn).not.toMatch(/claudePath,\s*['"](?:-p|--print)['"]/); - }); -}); diff --git a/browse/test/sidebar-tabs.test.ts b/browse/test/sidebar-tabs.test.ts deleted file mode 100644 index 682b0d962..000000000 --- a/browse/test/sidebar-tabs.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Regression: sidebar layout invariants after the chat-tab rip. - * - * The Chrome side panel used to host two surfaces: Chat (one-shot - * `claude -p` queue) and Terminal (interactive PTY). Chat was ripped - * once the PTY proved out — sidebar-agent.ts is gone, the chat queue - * endpoints are gone, and the primary-tab nav (Terminal | Chat) is - * gone. Terminal is now the sole primary surface. - * - * This file locks the load-bearing invariants of that layout so a - * future refactor can't silently re-introduce the old surface or break - * the new one. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const HTML = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.html'), 'utf-8'); -const JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel.js'), 'utf-8'); -const TERM_JS = fs.readFileSync(path.join(import.meta.dir, '../../extension/sidepanel-terminal.js'), 'utf-8'); -const MANIFEST = JSON.parse(fs.readFileSync(path.join(import.meta.dir, '../../extension/manifest.json'), 'utf-8')); - -describe('sidebar: chat tab + nav are removed, Terminal is sole primary surface', () => { - test('No primary-tab nav element exists', () => { - expect(HTML).not.toContain('class="primary-tabs"'); - expect(HTML).not.toContain('data-pane="chat"'); - expect(HTML).not.toContain('data-pane="terminal"'); - }); - - test('No
pane', () => { - expect(HTML).not.toMatch(/]*id="tab-chat"/); - expect(HTML).not.toContain('id="chat-messages"'); - expect(HTML).not.toContain('id="chat-loading"'); - expect(HTML).not.toContain('id="chat-welcome"'); - }); - - test('No chat input / send button / experimental banner', () => { - expect(HTML).not.toContain('class="command-bar"'); - expect(HTML).not.toContain('id="command-input"'); - expect(HTML).not.toContain('id="send-btn"'); - expect(HTML).not.toContain('id="stop-agent-btn"'); - expect(HTML).not.toContain('id="experimental-banner"'); - }); - - test('No clear-chat button in footer', () => { - expect(HTML).not.toContain('id="clear-chat"'); - }); - - test('Terminal pane is .active by default and has the toolbar', () => { - expect(HTML).toMatch(/]*id="tab-terminal"[^>]*class="tab-content active"/); - expect(HTML).toContain('id="terminal-toolbar"'); - expect(HTML).toContain('id="terminal-restart-now"'); - }); - - test('Quick-actions buttons (Cleanup / Screenshot / Cookies) survive in the terminal toolbar', () => { - // Garry explicitly wanted these kept after the chat rip — they drive - // browser actions, not chat. - expect(HTML).toContain('id="chat-cleanup-btn"'); - expect(HTML).toContain('id="chat-screenshot-btn"'); - expect(HTML).toContain('id="chat-cookies-btn"'); - // They live inside the terminal toolbar now (siblings of the Restart - // button), not as a separate strip below all panes. - const toolbarStart = HTML.indexOf('id="terminal-toolbar"'); - const toolbarEnd = HTML.indexOf('', toolbarStart); - const toolbarBlock = HTML.slice(toolbarStart, toolbarEnd + 6); - expect(toolbarBlock).toContain('id="chat-cleanup-btn"'); - expect(toolbarBlock).toContain('id="chat-screenshot-btn"'); - expect(toolbarBlock).toContain('id="chat-cookies-btn"'); - }); -}); - -describe('sidepanel.js: chat helpers ripped, terminal-injection helper survives', () => { - test('No primary-tab click handler', () => { - expect(JS).not.toContain("querySelectorAll('.primary-tab')"); - expect(JS).not.toContain('activePrimaryPaneId'); - }); - - test('No chat polling, sendMessage, sendChat, stopAgent, or pollTabs', () => { - expect(JS).not.toContain('chatPollInterval'); - expect(JS).not.toContain('function sendMessage'); - expect(JS).not.toContain('function pollChat'); - expect(JS).not.toContain('function pollTabs'); - expect(JS).not.toContain('function switchChatTab'); - expect(JS).not.toContain('function stopAgent'); - expect(JS).not.toContain('function applyChatEnabled'); - expect(JS).not.toContain('function showSecurityBanner'); - }); - - test('Cleanup runs through the live PTY (no /sidebar-command POST)', () => { - // The new Cleanup handler injects the prompt straight into claude's - // PTY via gstackInjectToTerminal. The dead code path was a POST to - // /sidebar-command which kicked off a fresh claude -p subprocess. - const cleanup = JS.slice(JS.indexOf('async function runCleanup')); - expect(cleanup).toContain('window.gstackInjectToTerminal'); - expect(cleanup).not.toContain('/sidebar-command'); - expect(cleanup).not.toContain('addChatEntry'); - }); - - test('Inspector "Send to Code" routes through the live PTY', () => { - const sendBtn = JS.slice(JS.indexOf('inspectorSendBtn.addEventListener')); - expect(sendBtn).toContain('window.gstackInjectToTerminal'); - expect(sendBtn).not.toContain("type: 'sidebar-command'"); - }); - - test('updateConnection no longer kicks off chat / tab polling', () => { - const update = JS.slice(JS.indexOf('function updateConnection'), JS.indexOf('function updateConnection') + 1500); - expect(update).not.toContain('chatPollInterval'); - expect(update).not.toContain('tabPollInterval'); - expect(update).not.toContain('pollChat'); - expect(update).not.toContain('pollTabs'); - // BUT must still expose the bootstrap globals for sidepanel-terminal.js. - expect(update).toContain('window.gstackServerPort'); - expect(update).toContain('window.gstackAuthToken'); - }); -}); - -describe('sidepanel-terminal.js: eager auto-connect + injection API', () => { - test('Exposes window.gstackInjectToTerminal for cross-pane use', () => { - expect(TERM_JS).toContain('window.gstackInjectToTerminal'); - // Returns false when no live session, true when bytes go out. - const inject = TERM_JS.slice(TERM_JS.indexOf('window.gstackInjectToTerminal')); - expect(inject).toContain('return false'); - expect(inject).toContain('return true'); - expect(inject).toContain('ws.readyState !== WebSocket.OPEN'); - }); - - test('Auto-connects on init (no keypress required)', () => { - expect(TERM_JS).not.toContain('function onAnyKey'); - expect(TERM_JS).not.toContain("addEventListener('keydown'"); - expect(TERM_JS).toContain('function tryAutoConnect'); - }); - - test('Repaint hook fires when Terminal pane becomes visible', () => { - // The chat-tab rip removed gstack:primary-tab-changed; we use a - // MutationObserver on #tab-terminal's class attr instead. The - // observer must call repaintIfLive when the .active class returns. - expect(TERM_JS).toContain('MutationObserver'); - expect(TERM_JS).toContain("attributeFilter: ['class']"); - expect(TERM_JS).toContain('repaintIfLive'); - const repaint = TERM_JS.slice(TERM_JS.indexOf('function repaintIfLive')); - expect(repaint).toContain('fitAddon && fitAddon.fit()'); - expect(repaint).toContain('term.refresh'); - expect(repaint).toContain("type: 'resize'"); - }); - - test('No auto-reconnect on close (Restart is user-initiated)', () => { - const closeOnly = TERM_JS.slice( - TERM_JS.indexOf("ws.addEventListener('close'"), - TERM_JS.indexOf("ws.addEventListener('error'"), - ); - expect(closeOnly).not.toContain('setTimeout'); - expect(closeOnly).not.toContain('tryAutoConnect'); - expect(closeOnly).not.toContain('connect()'); - }); - - test('forceRestart uses the session-scoped restart transaction and resets local state', () => { - expect(TERM_JS).toContain('function forceRestart'); - const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart')); - expect(fn).toContain("ws && ws.close(4001, 'intentional-restart')"); - expect(fn).toContain('term.dispose()'); - expect(fn).toContain('STATE.IDLE'); - expect(fn).toContain('/pty-restart'); - expect(fn).toContain('priorSessionId'); - expect(fn).toContain('tryAutoConnect()'); - }); - - test('Both restart buttons (mid-session and ENDED) call forceRestart', () => { - expect(TERM_JS).toContain("els.restart?.addEventListener('click', forceRestart)"); - expect(TERM_JS).toContain("els.restartNow?.addEventListener('click', forceRestart)"); - }); -}); - -describe('server.ts: chat / sidebar-agent endpoints are gone', () => { - const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); - - test('No /sidebar-command, /sidebar-chat, /sidebar-agent/* routes', () => { - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-command['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-chat['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname\.startsWith\(['"]\/sidebar-agent\//); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-agent\/event['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-tabs['"]/); - expect(SERVER_SRC).not.toMatch(/url\.pathname === ['"]\/sidebar-session['"]/); - }); - - test('No chat-related state declarations or helpers', () => { - // Allow the symbol names inside the rip-marker comments — but no - // `let`, `const`, `function`, or `interface` declarations of them. - expect(SERVER_SRC).not.toMatch(/^let agentProcess/m); - expect(SERVER_SRC).not.toMatch(/^let agentStatus/m); - expect(SERVER_SRC).not.toMatch(/^let messageQueue/m); - expect(SERVER_SRC).not.toMatch(/^let sidebarSession/m); - expect(SERVER_SRC).not.toMatch(/^const tabAgents/m); - expect(SERVER_SRC).not.toMatch(/^function pickSidebarModel/m); - expect(SERVER_SRC).not.toMatch(/^function processAgentEvent/m); - expect(SERVER_SRC).not.toMatch(/^function killAgent/m); - expect(SERVER_SRC).not.toMatch(/^function addChatEntry/m); - expect(SERVER_SRC).not.toMatch(/^interface ChatEntry/m); - expect(SERVER_SRC).not.toMatch(/^interface SidebarSession/m); - }); - - test('/health no longer surfaces agentStatus or messageQueue length', () => { - const health = SERVER_SRC.slice(SERVER_SRC.indexOf("url.pathname === '/health'")); - const slice = health.slice(0, 2000); - expect(slice).not.toContain('agentStatus'); - expect(slice).not.toContain('messageQueue'); - expect(slice).not.toContain('agentStartTime'); - // chatEnabled is hardcoded false now (older clients still see the field). - expect(slice).toMatch(/chatEnabled:\s*false/); - // terminalPort survives. - expect(slice).toContain('terminalPort'); - }); -}); - -describe('cli.ts: sidebar-agent is no longer spawned', () => { - const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); - - test('No Bun.spawn of sidebar-agent.ts', () => { - expect(CLI_SRC).not.toMatch(/Bun\.spawn\(\s*\['bun',\s*'run',\s*\w*[Aa]gent[Ss]cript\][\s\S]{0,300}sidebar-agent/); - // The variable name `agentScript` was for sidebar-agent. After the - // rip there's only termAgentScript. Allow comments to mention the - // history but not active spawn calls. - expect(CLI_SRC).not.toMatch(/^\s*let agentScript = path\.resolve/m); - }); - - test('Terminal-agent spawn survives', () => { - expect(CLI_SRC).toContain("import { spawnTerminalAgent } from './terminal-agent-control'"); - expect(CLI_SRC).toMatch(/spawnTerminalAgent\(\{[\s\S]*?stateFile:[\s\S]*?serverPort:[\s\S]*?cwd:/); - }); -}); - -describe('files: sidebar-agent.ts and its tests are deleted', () => { - test('browse/src/sidebar-agent.ts is gone', () => { - expect(fs.existsSync(path.join(import.meta.dir, '../src/sidebar-agent.ts'))).toBe(false); - }); - - test('sidebar-agent test files are gone', () => { - expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent.test.ts'))).toBe(false); - expect(fs.existsSync(path.join(import.meta.dir, 'sidebar-agent-roundtrip.test.ts'))).toBe(false); - }); -}); - -describe('manifest: ws permission + xterm-safe CSP', () => { - test('host_permissions covers ws localhost', () => { - expect(MANIFEST.host_permissions).toContain('ws://127.0.0.1:*/'); - }); - - test('host_permissions still covers http localhost', () => { - expect(MANIFEST.host_permissions).toContain('http://127.0.0.1:*/'); - }); - - test('manifest does NOT add unsafe-eval to extension_pages CSP', () => { - const csp = MANIFEST.content_security_policy; - if (csp && csp.extension_pages) { - expect(csp.extension_pages).not.toContain('unsafe-eval'); - } - }); -}); - -describe('manifest: live tab awareness needs "tabs" permission', () => { - // Without "tabs", chrome.tabs.query() returns tab objects with undefined - // url/title for any site outside host_permissions (e.g., everything except - // 127.0.0.1). snapshotTabs() then writes empty strings into tabs.json and - // active-tab.json silently skips the write — the sidebar agent loses track - // of what page the user is on. activeTab is too narrow (only after a user - // gesture on the extension action) for background polling. - test('permissions includes "tabs"', () => { - expect(MANIFEST.permissions).toContain('tabs'); - }); -}); diff --git a/browse/test/sidebar-unit.test.ts b/browse/test/sidebar-unit.test.ts deleted file mode 100644 index 3c0459a04..000000000 --- a/browse/test/sidebar-unit.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Layer 1: Unit tests for sidebar utilities. - * Tests pure functions — no server, no processes, no network. - */ - -import { describe, test, expect } from 'bun:test'; -import { sanitizeExtensionUrl } from '../src/sidebar-utils'; - -describe('sanitizeExtensionUrl', () => { - test('passes valid http URL', () => { - expect(sanitizeExtensionUrl('http://example.com')).toBe('http://example.com/'); - }); - - test('passes valid https URL', () => { - expect(sanitizeExtensionUrl('https://example.com/page?q=1')).toBe('https://example.com/page?q=1'); - }); - - test('rejects chrome:// URLs', () => { - expect(sanitizeExtensionUrl('chrome://extensions')).toBeNull(); - }); - - test('rejects chrome-extension:// URLs', () => { - expect(sanitizeExtensionUrl('chrome-extension://abcdef/popup.html')).toBeNull(); - }); - - test('rejects javascript: URLs', () => { - expect(sanitizeExtensionUrl('javascript:alert(1)')).toBeNull(); - }); - - test('rejects file:// URLs', () => { - expect(sanitizeExtensionUrl('file:///etc/passwd')).toBeNull(); - }); - - test('rejects data: URLs', () => { - expect(sanitizeExtensionUrl('data:text/html,

hi

')).toBeNull(); - }); - - test('strips raw control characters from URL', () => { - // URL constructor percent-encodes \x00 as %00, which is safe - // The regex strips any remaining raw control chars after .href normalization - const result = sanitizeExtensionUrl('https://example.com/\x00page\x1f'); - expect(result).not.toBeNull(); - expect(result!).not.toMatch(/[\x00-\x1f\x7f]/); - }); - - test('strips newlines (prompt injection vector)', () => { - const result = sanitizeExtensionUrl('https://evil.com/%0AUser:%20ignore'); - // URL constructor normalizes %0A, control char stripping removes any raw newlines - expect(result).not.toBeNull(); - expect(result!).not.toContain('\n'); - }); - - test('truncates URLs longer than 2048 chars', () => { - const longUrl = 'https://example.com/' + 'a'.repeat(3000); - const result = sanitizeExtensionUrl(longUrl); - expect(result).not.toBeNull(); - expect(result!.length).toBeLessThanOrEqual(2048); - }); - - test('returns null for null input', () => { - expect(sanitizeExtensionUrl(null)).toBeNull(); - }); - - test('returns null for undefined input', () => { - expect(sanitizeExtensionUrl(undefined)).toBeNull(); - }); - - test('returns null for empty string', () => { - expect(sanitizeExtensionUrl('')).toBeNull(); - }); - - test('returns null for invalid URL string', () => { - expect(sanitizeExtensionUrl('not a url at all')).toBeNull(); - }); - - test('does not crash on weird input', () => { - expect(sanitizeExtensionUrl(':///')).toBeNull(); - expect(sanitizeExtensionUrl(' ')).toBeNull(); - expect(sanitizeExtensionUrl('\x00\x01\x02')).toBeNull(); - }); - - test('preserves query parameters and fragments', () => { - const url = 'https://example.com/search?q=test&page=2#results'; - expect(sanitizeExtensionUrl(url)).toBe(url); - }); - - test('preserves port numbers', () => { - expect(sanitizeExtensionUrl('http://localhost:3000/api')).toBe('http://localhost:3000/api'); - }); - - test('handles URL with auth (user:pass@host)', () => { - const result = sanitizeExtensionUrl('https://user:pass@example.com/'); - expect(result).not.toBeNull(); - expect(result).toContain('example.com'); - }); -}); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts deleted file mode 100644 index 98fc7ee97..000000000 --- a/browse/test/sidebar-ux.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Source-contract tests for the terminal-first browser sidepanel. - * - * The one-shot chat queue and sidebar-agent daemon were removed. These - * checks intentionally cover the current PTY surface and its retained debug - * tools without preserving obsolete chat implementation details. - */ - -import { describe, test, expect } from 'bun:test'; -import * as fs from 'fs'; -import * as path from 'path'; - -const BROWSE_ROOT = path.resolve(import.meta.dir, '..'); -const REPO_ROOT = path.resolve(BROWSE_ROOT, '..'); -const EXTENSION_ROOT = path.join(REPO_ROOT, 'extension'); - -const html = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.html'), 'utf8'); -const sidepanel = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel.js'), 'utf8'); -const terminal = fs.readFileSync(path.join(EXTENSION_ROOT, 'sidepanel-terminal.js'), 'utf8'); -const background = fs.readFileSync(path.join(EXTENSION_ROOT, 'background.js'), 'utf8'); - -function between(source: string, startMarker: string, endMarker: string): string { - const start = source.indexOf(startMarker); - if (start < 0) return ''; - const end = source.indexOf(endMarker, start + startMarker.length); - return end < 0 ? source.slice(start) : source.slice(start, end); -} - -function withoutComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/^\s*\/\/.*$/gm, ''); -} - -describe('terminal-first sidepanel', () => { - test('terminal is the sole active primary pane', () => { - const activeMainIds = [...html.matchAll( - / match[1]); - - expect(activeMainIds).toEqual(['tab-terminal']); - expect(html).toContain('id="tab-terminal"'); - expect(html).toContain('role="tabpanel" aria-label="Terminal"'); - expect(html).not.toContain('id="tab-chat"'); - expect(sidepanel).toContain("const PRIMARY_PANE_ID = 'tab-terminal';"); - expect(sidepanel).toContain('document.getElementById(PRIMARY_PANE_ID).classList.add(\'active\')'); - }); - - test('xterm, fit, and terminal bootstrap assets are shipped and ordered', () => { - const assets = [ - 'lib/xterm.css', - 'lib/xterm.js', - 'lib/xterm-addon-fit.js', - 'sidepanel-terminal.js', - ]; - for (const asset of assets) { - expect(fs.existsSync(path.join(EXTENSION_ROOT, asset))).toBe(true); - expect(html).toContain(asset); - } - - const scriptOrder = [ - html.indexOf('lib/xterm.js'), - html.indexOf('lib/xterm-addon-fit.js'), - html.indexOf('sidepanel.js'), - html.indexOf('sidepanel-terminal.js'), - ]; - expect(scriptOrder.every((index) => index >= 0)).toBe(true); - expect(scriptOrder).toEqual([...scriptOrder].sort((left, right) => left - right)); - - for (const id of [ - 'terminal-bootstrap', - 'terminal-bootstrap-status', - 'terminal-install-card', - 'terminal-mount', - 'terminal-ended', - 'terminal-restart', - 'terminal-restart-now', - ]) { - expect(html).toContain(`id="${id}"`); - } - expect(terminal).toContain("setState(STATE.IDLE, { message: 'Starting Claude Code...' })"); - expect(terminal).toContain('tryAutoConnect();'); - }); - - test('retired chat queue code and daemon stay removed', () => { - const executableSidepanel = withoutComments(sidepanel); - const executableTerminal = withoutComments(terminal); - const removedFunctions = ['sendMessage', 'pollChat', 'switchChatTab']; - - expect(fs.existsSync(path.join(BROWSE_ROOT, 'src', 'sidebar-agent.ts'))).toBe(false); - for (const name of removedFunctions) { - const declaration = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`); - expect(executableSidepanel).not.toMatch(declaration); - expect(executableTerminal).not.toMatch(declaration); - } - expect(executableSidepanel).not.toContain('/sidebar-chat'); - expect(executableSidepanel).not.toContain('/sidebar-command'); - expect(html).not.toContain('id="chat-input"'); - expect(html).not.toContain('id="chat-messages"'); - expect(html).not.toContain('id="stop-agent-btn"'); - }); -}); - -describe('PTY lifecycle security', () => { - test('bootstrap uses authenticated POST and a one-use WebSocket protocol token', () => { - const connection = between(sidepanel, 'function updateConnection(', '// ─── Port Configuration'); - const mint = between(terminal, 'async function mintSession()', 'function startReattachLoop('); - - expect(connection).toContain('window.gstackServerPort'); - expect(connection).toContain('window.gstackAuthToken'); - expect(mint).toContain('`http://127.0.0.1:${serverPort}/pty-session`'); - expect(mint).toContain("method: 'POST'"); - expect(mint).toContain("'Authorization': `Bearer ${token}`"); - expect(mint).toContain("credentials: 'include'"); - expect(terminal).toContain('const attachToken = minted.attachToken || minted.ptySessionToken'); - expect(terminal).toContain( - 'new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`])', - ); - expect(terminal).not.toContain('?token='); - }); - - test('session identity is retained only for explicit pagehide disposal', () => { - const disposal = sidepanel.slice(sidepanel.indexOf("window.addEventListener('pagehide'")); - - expect(terminal).toContain('currentSessionId = sessionId || null'); - expect(terminal).toContain('window.gstackPtySession = currentSessionId'); - expect(disposal).toContain('const sessionId = window.gstackPtySession'); - expect(disposal).toContain('const authToken = window.gstackAuthToken'); - expect(disposal).toContain('if (!sessionId || !authToken || !port) return'); - expect(disposal).toContain('JSON.stringify({ sessionId, authToken })'); - expect(disposal).toContain('navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob)'); - expect(disposal).not.toContain('?token='); - }); - - test('tab state crosses the extension boundary only through the live PTY relay', () => { - const push = between(background, 'async function pushTabState(', "chrome.tabs.onActivated.addListener"); - const sidepanelRelay = between(sidepanel, "if (msg.type === 'browserTabState')", '// ─── v1.44 pagehide'); - const terminalRelay = between( - terminal, - "document.addEventListener('gstack:tab-state'", - '// Repaint after a debug-tab', - ); - - expect(push).toContain("type: 'browserTabState'"); - expect(push).toContain('...snapshot'); - expect(background).toContain("pushTabState('activated')"); - expect(background).toContain("pushTabState('created')"); - expect(background).toContain("pushTabState('removed')"); - expect(sidepanelRelay).toContain("new CustomEvent('gstack:tab-state'"); - expect(sidepanelRelay).toContain('detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason }'); - expect(terminalRelay).toContain('if (!ws || ws.readyState !== WebSocket.OPEN) return'); - expect(terminalRelay).toContain("type: 'tabState'"); - expect(terminalRelay).toContain('active: ev.detail?.active'); - expect(terminalRelay).toContain('tabs: ev.detail?.tabs'); - }); - - test('page-derived inspector and cleanup prompts are scanned before PTY injection', () => { - const inspectorSend = between(sidepanel, "inspectorSendBtn.addEventListener('click'", '// ─── Quick Action Helpers'); - const cleanup = between(sidepanel, 'async function runCleanup(', 'async function runScreenshot('); - - for (const block of [inspectorSend, cleanup]) { - const scan = block.indexOf('gstackScanForPTYInject'); - const inject = block.indexOf('gstackInjectToTerminal'); - expect(scan).toBeGreaterThan(0); - expect(inject).toBeGreaterThan(scan); - expect(block).toContain("verdict === 'BLOCK'"); - expect(block).toContain("verdict === 'WARN'"); - } - }); -}); - -describe('retained debug tools and quick actions', () => { - test('activity, refs, and inspector remain hidden debug panels', () => { - const debugNav = between(html, '