/** * gstack browse server — persistent Chromium daemon * * Architecture: * Bun.serve HTTP on localhost → routes commands to Playwright * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush * Chromium crash → server EXITS with clear error (CLI auto-restarts) * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) * * State: * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) * Log files: /.gstack/browse-{console,network,dialog}.log * Port: random 10000-60000 (or BROWSE_PORT env for debug override) */ import { BrowserManager, markDaemonProcess } from './browser-manager'; import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { hasActivePicker } from './cookie-picker-routes'; import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, runContentFilters, type ContentFilterResult, markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers, } from './content-security'; import { writeSecureFile, mkdirSecure, appendSecureFile } from './file-permissions'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { initRegistry, validateToken as validateScopedToken, checkScope, checkDomain, checkRate, recordCommand, isRootToken, type TokenInfo, } from './token-registry'; import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config'; import { isSessionPersistEnabled, persistSessionState, restoreSessionState, sessionPersistIntervalMs, SESSION_STATE_FILE, } from './session-persist'; import { emitActivity } from './activity'; import { initAuditLog, writeAuditEntry } from './audit'; import { detachSession } from './cdp-inspector'; // 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 { findAvailablePort, formatExplicitPortUnavailableError, formatRandomPortUnavailableError, } from './port-allocator'; import { acquireAgentStateLock, readAgentRecord, clearAgentRecord, isOurAgent, isAgentRecordLive, isAgentRecordGone, stopAgentByRecord, spawnTerminalAgent } from './terminal-agent-control'; import { isProcessAlive } from './error-handling'; import { sanitizeBody, stripLoneSurrogates } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; import { writeReceipt } from '../../lib/egress-receipt'; import { redactProxyUrl } from './proxy-redact'; import { type XvfbHandle } from './xvfb'; import { logTunnelDenial } from './tunnel-denial-log'; import { validateSseSessionToken, extractSseCookie } from './sse-session-cookie'; import { ROUTES } from './routes'; import { dispatchRoute, type RouteContext, type Surface } from './routes/table'; import { TUNNEL_COMMANDS, canDispatchOverTunnel } from './routes/commands'; import { getInspectorSubscriberCount, clearInspectorSubscribers } from './routes/inspector'; import * as fs from 'fs'; import * as net from 'net'; import * as path from 'path'; import * as crypto from 'crypto'; const SERVER_INSTANCE_ID = crypto.randomUUID(); function removeOwnedDaemonStateQuiet(): void { try { const release = acquireAgentStateLock(path.dirname(config.stateFile), 0); try { const state = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); if (state.pid === process.pid && state.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile); } finally { release(); } } catch {} } // ─── Unicode Sanitization ─────────────────────────────────────── // Unpaired UTF-16 surrogate halves (\uD800–\uDFFF) in page DOM text, OCR // output, and other CDP-sourced strings are rejected by JSON consumers // downstream (Anthropic API in particular: "no low surrogate in string"). // The sanitizers live in sanitize.ts (single source of truth, shared with // sse-helpers.ts and the read/snapshot pipeline): `stripLoneSurrogates` // replaces lone halves with U+FFFD (valid pairs like emoji survive), and // `sanitizeReplacer` runs it on every string value inside JSON.stringify. // // INVARIANT: every server egress path that ships page-content strings MUST // route through the sanitizer. handleCommandInternal wraps the final // cr.result string (text/plain bodies carry lone surrogates verbatim; // JSON.stringify already escapes them). The SSE producers stringify with // `sanitizeReplacer` so payload string fields get cleaned BEFORE escaping. // Plain post-stringify regex is a no-op there because JSON.stringify // converts \uD800 → "\\ud800" — the regex can't see the surrogate after // that point. // ─── Config ───────────────────────────────────────────────────── const config = resolveConfig(); ensureStateDir(config); initAuditLog(config.auditLog); // ─── Auth ─────────────────────────────────────────────────────── // activeShutdown points to the factory-scoped shutdown function once // buildFetchHandler has been called. Module-level timers (idle check, parent // watchdog) and signal handlers route through activeShutdown so they close // the cfg-provided browserManager rather than a stale module-level reference. // Null before the first buildFetchHandler call, which is correct: nothing to // shut down yet. let activeShutdown: ((code?: number) => Promise) | null = null; // AUTH_TOKEN is injectable via process.env.AUTH_TOKEN so embedders // (gbrowser's gbd daemon spawn) can pre-allocate the token and hand it to // the Bun child via env. // // Validation: require >= 16 chars after stripping ALL unicode whitespace // (not just ASCII — .trim() misses U+200B / U+FEFF / U+00A0 / etc., which // would otherwise let a misconfigured embedder ship a one-character BOM as // the bearer secret). Reject tokens that are too short or contain only // whitespace; fall back to randomUUID so the security boundary is never // silently weakened by misconfiguration. function sanitizeAuthToken(raw: string | undefined): string | null { if (!raw) return null; const stripped = raw.replace(/[\s ​-‍]/g, ''); if (stripped.length < 16) return null; return stripped; } // AUTH_TOKEN const + module-level initRegistry call deleted in v1.35.0.0. // buildFetchHandler now owns auth state end-to-end: cfg.authToken is the // single source of truth, factory body calls initRegistry(cfg.authToken), // and factory-scoped validateAuth closes over the same value. start() reads // env once via resolveConfigFromEnv() and threads the result through. const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min /** * Port the local listener bound to. Set once the daemon picks a port. * Used by `$B skill run` to point spawned skill scripts at the daemon over * loopback. Module-level so handleCommandInternal can read it without threading * the port through every dispatch. */ let LOCAL_LISTEN_PORT: number = 0; // Sidebar chat is always enabled in headed mode (ungated in v0.12.0) // ─── Tunnel State ─────────────────────────────────────────────── // // Dual-listener architecture: the daemon binds TWO HTTP listeners when a // tunnel is active. The local listener serves bootstrap + CLI + sidebar // (never exposed to ngrok). The tunnel listener serves only the pairing // ceremony and scoped-token command endpoints (the ONLY port ngrok forwards). // // Security property comes from physical port separation: a tunnel caller // cannot reach bootstrap endpoints because they live on a different TCP // socket, not because of any per-request check. let tunnelActive = false; let tunnelUrl: string | null = null; let tunnelListener: any = null; // ngrok listener handle let tunnelServer: ReturnType | null = null; // tunnel HTTP listener export type { Surface }; /** * Factory contract for embedders (gbrowser phoenix overlay). * * Today the CLI calls `start()` which reads env vars and binds Bun.serve * itself. Embedders building on this server as a submodule (gbrowser's * fd-passing gbd architecture) need to inject auth + ports + a * BrowserManager they pre-launched, and own the listener themselves. * * Status: v1 surfaces this type as documentation. AUTH_TOKEN env-injection * is already live (see ~L70). `start()` is exported and the kickoff / * signal-handler registration is gated on `import.meta.main`, so phoenix * can `import { start } from '.../server'` without auto-starting. Full * `buildFetchHandler` extraction lands in a follow-up; see plan * `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md` * Part 1. */ export interface ServerConfig { /** Bearer token clients must present. Today injected via AUTH_TOKEN env. */ authToken: string; /** Local listener port. Used in /welcome URL + state-file. */ browsePort: number; /** Result of resolveConfig() — stateDir, auditLog, stateFile. */ config: ReturnType; /** Pre-launched BrowserManager. Caller owns lifecycle. */ browserManager: BrowserManager; // NOTE: per-factory idleTimeoutMs and chromiumProfile were deleted — they // were documented but never read (the idle timer, activity state, and // shutdown target are module-global, so per-factory wiring would lie for // any embedder running >1 handler). Real support belongs to the deferred // server.ts singleton refactor. Until then: BROWSE_IDLE_TIMEOUT // and CHROMIUM_PROFILE env are the honest knobs. /** Caller-owned. shutdown() does NOT call xvfb.stop(); caller is responsible. */ xvfb?: XvfbHandle | null; /** Caller-owned. shutdown() does NOT call proxyBridge.close(); caller is responsible. */ proxyBridge?: BridgeHandle | null; startTime: number; /** * Overlay hook. Runs AFTER gstack resolves auth and BEFORE route dispatch. * Invalid tokens are auto-rejected at the gstack layer (401 returned * before hook fires), so the hook only ever sees valid TokenInfo or null * (no token presented). Returning a Response short-circuits gstack * 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; } /** * Return shape of buildFetchHandler() — fetch handlers + lifecycle helpers * embedders need to drive their own Bun.serve binding. See ServerConfig. */ export interface ServerHandle { fetchLocal: (req: Request, server: any) => Promise; fetchTunnel: (req: Request, server: any) => Promise; /** * Drains buffers, kills terminal-agent, 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. */ shutdown: (exitCode?: number) => Promise; /** * Graceful listener stop for embedders. Calls server.stop(true) on each * passed Bun.Server. CLI doesn't need this (process.exit handles it). */ stopListeners: (local: any, tunnel?: any) => Promise; } /** * Build a ServerConfig-shaped object from process.env. Used by gstack's * own CLI when running `bun run dev` or the compiled binary directly. * Embedders construct their own ServerConfig explicitly. * * Reads env, calls resolveConfig(). Does NOT bind a listener or call * initAuditLog/initRegistry — those happen inside the buildFetchHandler * lifecycle. */ export function resolveConfigFromEnv(): Omit & { config: ReturnType; } { return { // Same sanitizer as the module-level AUTH_TOKEN: strips ALL unicode // whitespace and rejects tokens shorter than 16 chars so a misconfigured // embedder can't ship a BOM/zero-width as the bearer secret. authToken: sanitizeAuthToken(process.env.AUTH_TOKEN) || crypto.randomUUID(), browsePort: parseInt(process.env.BROWSE_PORT || '0', 10), config: resolveConfig(), }; } /** * Paths reachable over the tunnel surface. Everything else returns 404. * * `/connect` is the only unauthenticated tunnel endpoint — POST for setup-key * exchange, GET for an `{alive: true}` probe used by /pair and /tunnel/start * to detect dead ngrok tunnels. Other paths in this set require a scoped * token via Authorization: Bearer. * * Updating this set is a deliberate security decision. Every addition widens * the tunnel attack surface. It must equal the paths of the route-table * entries that declare the 'tunnel' surface (browse/test/server-route-table.test.ts), * so widening the tunnel means editing this literal and the entry. */ const TUNNEL_PATHS = new Set([ '/connect', '/command', ]); /** * The gstack sidebar extension's pinned Chrome extension ID. Derived from * the "key" field in extension/manifest.json (first 16 bytes of SHA-256 of * the DER public key, hex nibbles mapped 0-9a-f → a-p). Reproduce with: * bun browse/scripts/extension-id.ts * POST /extension-token releases AUTH_TOKEN only to an Origin of exactly * `chrome-extension://`. If the manifest keypair is ever rotated, * this constant must be updated in the same commit. */ export const GSTACK_EXTENSION_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep'; /** * The extension-origin auth check (POST /extension-token): Origin is exactly * the pinned extension and Host is loopback. Defense-in-depth alongside the * 127.0.0.1 bind: a DNS-rebinding page can't present a localhost Host header. * Host arrives as '127.0.0.1:34567', so parse out the hostname — never compare * the raw header (which carries the port) against a literal. */ function isPinnedExtensionRequest(req: Request): boolean { let hostname: string | null = null; try { hostname = new URL(`http://${req.headers.get('host') ?? ''}`).hostname; } catch (err) { if (!(err instanceof TypeError)) throw err; // TypeError = malformed Host } const originOk = req.headers.get('origin') === `chrome-extension://${GSTACK_EXTENSION_ID}`; const hostOk = hostname === '127.0.0.1' || hostname === 'localhost'; return originOk && hostOk; } export { TUNNEL_COMMANDS, canDispatchOverTunnel }; /** * Read ngrok authtoken from env var, ~/.gstack/ngrok.env, or ngrok's native * config files. Returns null if nothing found. Shared between the * /tunnel/start handler and the BROWSE_TUNNEL=1 auto-start flow. */ function resolveNgrokAuthtoken(): string | null { let authtoken = process.env.NGROK_AUTHTOKEN; if (authtoken) return authtoken; const home = process.env.HOME || ''; const ngrokEnvPath = path.join(home, '.gstack', 'ngrok.env'); if (fs.existsSync(ngrokEnvPath)) { try { const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8'); const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m); if (match) return match[1].trim(); } catch {} } const ngrokConfigs = [ path.join(home, 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), path.join(home, '.config', 'ngrok', 'ngrok.yml'), path.join(home, '.ngrok2', 'ngrok.yml'), ]; for (const conf of ngrokConfigs) { try { const content = fs.readFileSync(conf, 'utf-8'); const match = content.match(/authtoken:\s*(.+)/); if (match) return match[1].trim(); } catch {} } return null; } /** * Tear down the tunnel: close the ngrok listener and stop the tunnel-surface * Bun.serve listener. Safe to call with nothing running. Always clears * tunnel state regardless of individual close failures. */ async function closeTunnel(): Promise { try { if (tunnelListener) await tunnelListener.close(); } catch {} try { if (tunnelServer) tunnelServer.stop(true); } catch {} tunnelListener = null; tunnelServer = null; tunnelUrl = null; tunnelActive = false; } /** * Result of startTunnel(). `stage` tells the caller which half failed so it * can keep its distinct error surface: 'bind' = the tunnel-surface Bun.serve * listener could not bind (nothing to clean up), 'ngrok' = anything after the * bind (ngrok forward, egress receipt, state-file write) — startTunnel has * already torn down both ngrok and the Bun listener by the time it returns. */ type StartTunnelResult = | { ok: true; url: string } | { ok: false; stage: 'bind' | 'ngrok'; error: Error }; /** * Start the ngrok tunnel using the dual-listener pattern: bind a dedicated * tunnel-surface listener on an ephemeral 127.0.0.1 port and point * ngrok.forward() at THAT port — the local listener (which serves * /extension-token, /cookie-picker, /inspector/*, welcome, etc.) is never * exposed to ngrok. Shared by the /tunnel/start route handler (which passes * its in-closure makeFetchHandler('tunnel')) and the BROWSE_TUNNEL=1 * auto-start flow in start() (which passes handle.fetchTunnel from the * factory). The BROWSE_TUNNEL_LOCAL_ONLY=1 test path does NOT use this * helper — it binds the tunnel surface with no ngrok forwarding at all. * * Hard fail on listener bind (`stage: 'bind'`) — NEVER fall back to the * local port, which would silently defeat the whole security property. * * On success, sets the module tunnel state (tunnelListener / tunnelUrl / * tunnelServer / tunnelActive) and records the tunnel in the state file. */ async function startTunnel(opts: { fetchHandler: (req: Request, server: any) => Promise; authtoken: string; consent: string; }): Promise { // Bind the tunnel listener on an ephemeral port. HARD FAIL if this // errors — never fall back to the local port. let boundTunnel: ReturnType; try { boundTunnel = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: opts.fetchHandler, }); } catch (err: any) { return { ok: false, stage: 'bind', error: err }; } const tunnelPort = boundTunnel.port; // Point ngrok at the TUNNEL port (not the local port). If this fails, // tear the listener back down so we don't leak sockets. try { const ngrok = await import('@ngrok/ngrok'); const domain = process.env.NGROK_DOMAIN; const forwardOpts: any = { addr: tunnelPort, authtoken: opts.authtoken }; if (domain) forwardOpts.domain = domain; // Egress receipt BEFORE the tunnel session opens, fail-closed: a // writeReceipt failure lands in this catch, which tears the tunnel // listener back down and refuses the start. One receipt per session // open; browse command behavior over the tunnel is unchanged. writeReceipt({ sink: 'browse-tunnel', host: domain || 'connect.ngrok-agent.com', payloadClass: 'tunnel-session-open (scoped-token browser-command surface)', bytes: 0, sha256: null, consent: opts.consent, }); tunnelListener = await ngrok.forward(forwardOpts); tunnelUrl = tunnelListener.url(); tunnelServer = boundTunnel; tunnelActive = true; console.log(`[browse] Tunnel listener bound on 127.0.0.1:${tunnelPort}, ngrok → ${tunnelUrl}`); // Update state file const releaseStateLock = acquireAgentStateLock(config.stateDir); try { const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced'); stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() }; const tmpState = tmpStatePath(); fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); fs.renameSync(tmpState, config.stateFile); } finally { releaseStateLock(); } return { ok: true, url: tunnelUrl! }; } catch (err: any) { // Clean up BOTH ngrok and the Bun listener on failure. If // ngrok.forward() succeeded but tunnelListener.url() or the // state-file write threw, we'd otherwise leak an active ngrok // session on the user's account. try { if (tunnelListener) await tunnelListener.close(); } catch {} try { boundTunnel.stop(true); } catch {} tunnelListener = null; return { ok: false, stage: 'ngrok', error: err }; } } // Module-level validateAuth deleted in v1.35.0.0. Factory-scoped equivalent // 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'); if (!header?.startsWith('Bearer ')) return null; return header.slice(7); } /** Validate token and return TokenInfo. Returns null if invalid/expired. */ function getTokenInfo(req: Request): TokenInfo | null { const token = extractToken(req); if (!token) return null; return validateScopedToken(token); } /** Check if request is from root token (local use). */ function isRootRequest(req: Request): boolean { const token = extractToken(req); return token !== null && isRootToken(token); } // ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ──────── function generateHelpText(): string { // Group commands by category const groups = new Map(); for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { const display = meta.usage || cmd; const list = groups.get(meta.category) || []; list.push(display); groups.set(meta.category, list); } const categoryOrder = [ 'Navigation', 'Reading', 'Interaction', 'Inspection', 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', ]; const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; for (const cat of categoryOrder) { const cmds = groups.get(cat); if (!cmds) continue; lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); } // Snapshot flags from source of truth lines.push(''); lines.push('Snapshot flags:'); const flagPairs: string[] = []; for (const flag of SNAPSHOT_FLAGS) { const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; flagPairs.push(`${label} ${flag.long}`); } // Print two flags per line for compact display for (let i = 0; i < flagPairs.length; i += 2) { const left = flagPairs[i].padEnd(28); const right = flagPairs[i + 1] || ''; lines.push(` ${left}${right}`); } return lines.join('\n'); } // ─── Buffer (from buffers.ts) ──────────────────────────────────── import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; const CONSOLE_LOG_PATH = config.consoleLog; const NETWORK_LOG_PATH = config.networkLog; const DIALOG_LOG_PATH = config.dialogLog; /** * Per-process state-file temp path. The state-file write pattern is * `writeFileSync(tmp, ...) → renameSync(tmp, stateFile)` for atomicity, * but a shared `${stateFile}.tmp` filename means two concurrent writers * (cold-start race when N CLIs hit a fresh repo simultaneously, parallel * /tunnel/start handlers, or a combination) collide on the rename: the * first writer's renameSync moves the shared temp file out of the way, * the second writer's writeFileSync re-creates it, the second rename * then races with the first writer's already-renamed state. Worst case * the second renameSync throws ENOENT mid-air, killing one of the * spawning daemons during startup. * * Per-process suffix (pid + 4 random bytes) makes each writer's temp * path unique. The atomic rename still gives last-writer-wins semantics * for the final state.json content; the only behavior change is that * concurrent writers no longer kill each other on the rename. */ function tmpStatePath(stateFile: string = config.stateFile): string { return `${stateFile}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`; } // ─── Sidebar agent / chat state ripped ────────────────────────────── let lastConsoleFlushed = 0; let lastNetworkFlushed = 0; let lastDialogFlushed = 0; let flushInProgress = false; async function flushBuffers() { if (flushInProgress) return; // Guard against concurrent flush flushInProgress = true; try { // Console buffer const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; if (newConsoleCount > 0) { const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` ).join('\n') + '\n'; appendSecureFile(CONSOLE_LOG_PATH, lines); lastConsoleFlushed = consoleBuffer.totalAdded; } // Network buffer const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; if (newNetworkCount > 0) { const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` ).join('\n') + '\n'; appendSecureFile(NETWORK_LOG_PATH, lines); lastNetworkFlushed = networkBuffer.totalAdded; } // Dialog buffer const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; if (newDialogCount > 0) { const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` ).join('\n') + '\n'; appendSecureFile(DIALOG_LOG_PATH, lines); lastDialogFlushed = dialogBuffer.totalAdded; } } catch (err: any) { console.error('[browse] Buffer flush failed:', err.message); } finally { flushInProgress = false; } } // Flush every 1 second const flushInterval = setInterval(flushBuffers, 1000); // ─── Idle Timer ──────────────────────────────────────────────── let lastActivity = Date.now(); function resetIdleTimer() { lastActivity = Date.now(); } // Named for behavioral testing via __testInternals__. The factory tests in // server-factory.test.ts call this directly so the idle-shutdown path can be // exercised without waiting 60s for the interval to fire. function idleCheckTick() { // Headed mode: the user is looking at the browser. Never auto-die. // Only shut down when the user explicitly disconnects or closes the window. // Reads via the activeBrowserManager indirection so embedders that pass // their own BrowserManager into buildFetchHandler hit the right instance. if (activeBrowserManager.getConnectionMode() === 'headed') return; // Tunnel mode: remote agents may send commands sporadically. Never auto-die. if (tunnelActive) return; if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); activeShutdown?.(); } } const idleCheckInterval = setInterval(idleCheckTick, 60_000); // Test-only surface for server-factory.test.ts. Lets the dual-instance // idle-timer behavior be exercised deterministically without mutating // Date.now (which would interact with the leaked module-level setInterval). // Production code must never import this — see `idle timer + onDisconnect // dual-instance fix` describe block for usage. export const __testInternals__ = { serverInstanceId: SERVER_INSTANCE_ID, tunnelPaths: TUNNEL_PATHS as ReadonlySet, idleCheckTick, // Watchdog seams (watchdog.test.ts): drive the 15s poll against an // arbitrary (dead) PID, trigger the handoff-promotion suppression exactly // as onHeadedPromotion does, and reset the latches between tests. parentWatchdogTick, suppressHeadedParentShutdown, resetParentWatchdogState: () => { headedParentShutdownSuppressed = false; parentGone = false; }, setTunnelActive: (v: boolean) => { tunnelActive = v; }, setLastActivity: (t: number) => { lastActivity = t; }, formatExplicitPortUnavailableError, formatRandomPortUnavailableError, // Reset the module-level shutdown latch so tests that drive shutdown to // completion (process.exit-stubbed) can be followed by tests that also // need shutdown to fire. Without this, the second test's shutdown // returns early at the `if (isShuttingDown) return;` guard. resetShutdownState: () => { isShuttingDown = false; }, }; // ─── Parent-Process Watchdog ──────────────────────────────────────── // When the spawning CLI process (e.g. a Claude Code session) exits, this // server can become an orphan — keeping chrome-headless-shell alive and // causing console-window flicker on Windows. Poll the parent PID every 15s // and self-terminate if it is gone. // // Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls // the browser window lifecycle. The CLI exits immediately after connect, // so the watchdog would kill the server prematurely. Disabled in both cases // as defense-in-depth — the CLI sets PID=0 for headed mode, and the server // also checks BROWSE_HEADED in case a future launcher forgets. // Cleanup happens via browser disconnect event or $B disconnect. const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10); // Outer gate: if the spawner explicitly marks this as headed (env var set at // launch time), skip registering the watchdog entirely. Cheaper than entering // the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0, // so this branch is the normal path for /open-gstack-browser. const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1'; // Runtime promotion to headed (`handoff`) must NOT clear this interval — the // same tick is the tunnel-orphan reaper, and idle timeout is disabled in // tunnel mode, so parent death is the ONLY thing that reaps an // internet-exposed daemon after handoff → resume → /pair-agent. Promotion // sets this suppress flag instead; the tick re-reads it (and tunnelActive) // every pass. See suppressHeadedParentShutdown() below. let headedParentShutdownSuppressed = false; // Latch for the one-time "parent exited, staying alive" log line. let parentGone = false; // Named + parameterized (default: the boot-time env PID) so watchdog.test.ts // can drive the tick deterministically via __testInternals__, mirroring // idleCheckTick above. setInterval invokes it with no args in production. function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void { try { process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent } catch { // Parent exited. Resolution order: // 1. Active cookie picker (one-time code or session live)? Stay alive // regardless of mode — tearing down the server mid-import leaves the // picker UI with a stale "Failed to fetch" error. // 2. Headed (unless suppressed by a runtime promotion) / tunnel mode? // Shutdown. The idle timeout doesn't apply in these modes (see // idleCheckInterval above — both early-return), so ignoring parent // death here would leak orphan daemons after /pair-agent or // /open-gstack-browser sessions. // 3. Normal (headless) mode, or headed-by-promotion? Stay alive. Claude // Code's Bash tool kills the parent shell between invocations, and a // promoted daemon's user owns the window lifecycle. The idle timeout // (30 min) handles eventual cleanup. if (hasActivePicker()) return; const headed = activeBrowserManager.getConnectionMode() === 'headed' && !headedParentShutdownSuppressed; if (headed || tunnelActive) { console.log(`[browse] Parent process ${parentPid} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); activeShutdown?.(); } else if (!parentGone) { parentGone = true; console.log(`[browse] Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`); } } } // Poll cadence. Env-overridable as a test seam: watchdog.test.ts shrinks it // (250ms) so a free-tier test can observe a real tick deciding on a dead // parent instead of sleeping through the 15s production cadence. Production // launchers never set this; unparsable or non-positive values fall back to 15s. const rawWatchdogIntervalMs = parseInt(process.env.BROWSE_PARENT_WATCHDOG_INTERVAL_MS || '', 10); const PARENT_WATCHDOG_INTERVAL_MS = Number.isFinite(rawWatchdogIntervalMs) && rawWatchdogIntervalMs > 0 ? rawWatchdogIntervalMs : 15_000; if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) { setInterval(parentWatchdogTick, PARENT_WATCHDOG_INTERVAL_MS); } else if (IS_HEADED_WATCHDOG) { console.log('[browse] Parent-process watchdog disabled (headed mode)'); } else if (BROWSE_PARENT_PID === 0) { console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)'); } /** * Suppress the headed-mode parent-death shutdown after a runtime promotion. * * The watchdog's contract is "headless daemons outlive their parent, headed ones * do not" — reasonable at boot, when mode is fixed by env. `handoff` breaks that * assumption: it swaps in a headed context on a RUNNING daemon * (browser-manager.ts, connectionMode = 'headed') without a restart, so a daemon * that legitimately registered a watchdog is suddenly on the fatal side of the * branch. The parent is typically a short-lived shell — Claude Code's Bash tool * kills one after every invocation — so the next 15s poll shuts the daemon down, * discarding whatever the user was handed off to do, such as a login. * * Once promoted, the user owns the window lifecycle exactly as if the daemon had * been started headed, which is the case the env guards already exempt. * * A flag, NOT clearInterval: the tick doubles as the tunnel-orphan reaper * (its `tunnelActive` branch), and idle timeout is disabled in tunnel mode — * clearing the whole interval here left handoff → resume → /pair-agent with * an internet-exposed daemon nothing could ever reap. After promotion, parent * death no longer kills the daemon for BEING HEADED, but still kills it when * a tunnel is active. */ function suppressHeadedParentShutdown(stateConfig: ServerConfig['config'] = config, manager: BrowserManager = activeBrowserManager): void { if (headedParentShutdownSuppressed) return; headedParentShutdownSuppressed = true; try { const release = acquireAgentStateLock(stateConfig.stateDir); try { const state = JSON.parse(fs.readFileSync(stateConfig.stateFile, 'utf8')); if (state.pid === process.pid && state.instanceId === SERVER_INSTANCE_ID) { const xvfb = manager.getXvfbHandle(); state.mode = 'headed'; delete state.chromiumPid; delete state.chromiumStartTime; if (xvfb) Object.assign(state, { xvfbPid: xvfb.pid, xvfbStartTime: xvfb.startTime, xvfbDisplay: xvfb.display }); const tmpFile = tmpStatePath(stateConfig.stateFile); try { fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, stateConfig.stateFile); } finally { safeUnlinkQuiet(tmpFile); } } } finally { release(); } } catch (err) { console.warn('[browse] Could not persist headed promotion:', err instanceof Error ? err.message : String(err)); } console.log('[browse] Parent-death headed shutdown suppressed (promoted to headed at runtime); watchdog stays armed as the tunnel-orphan reaper'); } // ─── Command Sets (from commands.ts — single source of truth) ─── import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; /** * Whether an invocation should be treated as a WRITE for capability gating * (scope, watch-mode block, tab ownership, tunnel). A command is a write if it * mutates state (`WRITE_COMMANDS`) OR it carries an `--out` flag — `js`/`eval * --out` writes the evaluate result to local disk, so the capability is * per-invocation, not per-command-name. This deliberately does NOT change * dispatch routing: `js`/`eval` still route to `handleReadCommand`; only the * security gates consult this. */ function isWriteInvocation(command: string, args: string[]): boolean { return WRITE_COMMANDS.has(command) || hasOutArg(args); } export { getInspectorSubscriberCount }; // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); // Declared here rather than beside suppressHeadedParentShutdown: that function // sits with the watchdog it gates, which is above this line, and binding it up // there would touch `browserManager` in its temporal dead zone — aborting // module evaluation and leaving every later const uninitialized. browserManager.onHeadedPromotion = suppressHeadedParentShutdown; // Indirection for embedders. Module-level handlers (idleCheckTick, parent // watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can // retarget them at a caller-supplied BrowserManager. Symmetric with the // existing `let activeShutdown` pattern at module scope (line ~113). // Without this, embedders like gbrowser hit the dead module-level instance // whose connectionMode never leaves 'launched' — and headed mode never // short-circuits idle-shutdown. let activeBrowserManager: BrowserManager = browserManager; // When the user closes the headed browser window, run full cleanup // (kill terminal agent, save session, remove profile locks, delete state file) // before exiting. Exit code 0 means user-initiated clean quit (Cmd+Q on // macOS) so process supervisors like gbrowser's gbd skip the restart loop; // 2 means a real crash that should respawn. The fallback `?? 2` preserves // legacy crash semantics for any caller that invokes onDisconnect without // an explicit code. This is the safety-net default for the CLI flow before // any buildFetchHandler call rebinds onDisconnect onto the cfg instance. browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2); let isShuttingDown = false; // Session-persist ticker handle. Registered in start() (module scope so the // factory's shutdown() can reach it), cleared by shutdown() BEFORE the final // snapshot — a tick landing during browser teardown would otherwise overwrite // the good final snapshot with a degraded one (zero tabs). let sessionPersistInterval: ReturnType | null = null; // Port allocation lives in port-allocator.ts (#2314, decision 8) so the // terminal-agent shares the SAME fixed 10000-60000 scan range instead of // binding port:0 into the OS ephemeral range. The imports at the top of // this file re-expose the pieces __testInternals__ pins. // Find port: explicit BROWSE_PORT, or random in 10000-60000 function findPort(): Promise { return findAvailablePort(BROWSE_PORT); } /** * Translate Playwright errors into actionable messages for AI agents. */ function wrapError(err: any): string { const msg = err.message || String(err); // Timeout errors if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; } if (msg.includes('page.goto') || msg.includes('Navigation')) { return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; } return `Operation timed out: ${msg.split('\n')[0]}`; } // Multiple elements matched if (msg.includes('resolved to') && msg.includes('elements')) { return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; } // Pass through other errors return msg; } /** Internal command result — used by handleCommand and chain subcommand routing */ interface CommandResult { status: number; result: string; headers?: Record; json?: boolean; // true if result is JSON (errors), false for text/plain } /** * Core command execution logic. Returns a structured result instead of HTTP Response. * Used by both the HTTP handler (handleCommand) and chain subcommand routing. * * Options: * skipRateCheck: true when called from chain (chain counts as 1 request) * skipActivity: true when called from chain (chain emits 1 event for all subcommands) * chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain) */ async function handleCommandInternalImpl( body: { command: string; args?: string[]; tabId?: number }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { const { args = [], tabId } = body; const rawCommand = body.command; if (!rawCommand) { return { status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), json: true }; } // ─── Alias canonicalization (before scope, watch, tab-ownership, dispatch) ─ // Agent-friendly names like 'setcontent' route to canonical 'load-html'. Must // happen BEFORE scope check so a read-scoped token calling 'setcontent' is still // rejected (load-html lives in SCOPE_WRITE). Audit logging preserves rawCommand // so the trail records what the agent actually typed. const command = canonicalizeCommand(rawCommand); const isAliased = command !== rawCommand; // ─── Recursion guard: reject nested chains ────────────────── if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) { return { status: 400, result: JSON.stringify({ error: 'Nested chain commands are not allowed' }), json: true }; } // ─── Scope check (for scoped tokens) ────────────────────────── if (tokenInfo && tokenInfo.clientId !== 'root') { if (!checkScope(tokenInfo, command)) { return { status: 403, json: true, result: JSON.stringify({ error: `Command "${command}" not allowed by your token scope`, hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Ask the user to re-pair without --restrict for full page access, or with --control for browser control commands.`, }), }; } // `--out` writes the evaluate result to local disk, which is a WRITE // capability distinct from the JS-exec (admin) capability js/eval need. // Require write scope so an admin-but-not-write token can't write files. if (hasOutArg(args) && !tokenInfo.scopes.includes('write')) { return { status: 403, json: true, result: JSON.stringify({ error: `"--out" writes to disk and requires the "write" scope`, hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Re-pair with write access to use --out.`, }), }; } // Domain check for navigation commands if ((command === 'goto' || command === 'newtab') && args[0]) { if (!checkDomain(tokenInfo, args[0])) { return { status: 403, json: true, result: JSON.stringify({ error: `Domain not allowed by your token scope`, hint: `Allowed domains: ${tokenInfo.domains?.join(', ') || 'none configured'}`, }), }; } } // Rate check (skipped for chain subcommands — chain counts as 1 request) if (!opts?.skipRateCheck) { const rateResult = checkRate(tokenInfo); if (!rateResult.allowed) { return { status: 429, json: true, result: JSON.stringify({ error: 'Rate limit exceeded', hint: `Max ${tokenInfo.rateLimit} requests/second. Retry after ${rateResult.retryAfterMs}ms.`, }), headers: { 'Retry-After': String(Math.ceil((rateResult.retryAfterMs || 1000) / 1000)) }, }; } } // Record command execution for idempotent key exchange tracking if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token); } // Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts). // This prevents parallel agents from interfering with each other's tab context. // Safe because Bun's event loop is single-threaded — no concurrent handleCommand. let savedTabId: number | null = null; if (tabId !== undefined && tabId !== null) { savedTabId = browserManager.getActiveTabId(); // bringToFront: false — internal tab pinning must NOT steal window focus try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) { console.warn('[browse] Failed to pin tab', tabId, ':', err.message); } } // ─── Tab ownership check (own-only tokens / pair-agent isolation) ── // // Only `own-only` tokens (pair-agent over tunnel) are bound to their own // tabs. `shared` tokens — the default for skill spawns and local scoped // clients — can drive any tab; the capability gate (scope checks above) // and rate limits already constrain what they can do. // // Skip for `newtab` — it creates a tab rather than accessing one. if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') { const targetTab = tabId ?? browserManager.getActiveTabId(); if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) { return { status: 403, json: true, result: JSON.stringify({ error: 'Tab not owned by your agent. Use newtab to create your own tab.', hint: `Tab ${targetTab} is owned by ${browserManager.getTabOwner(targetTab) || 'root'}. Your agent: ${tokenInfo.clientId}.`, }), }; } } // ─── newtab with ownership for scoped tokens ────────────── if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') { const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId); return { status: 200, json: true, result: JSON.stringify({ tabId: newId, owner: tokenInfo.clientId, hint: 'Include "tabId": ' + newId + ' in subsequent commands to target this tab.', }), }; } // Block mutation commands while watching (read-only observation mode). // `--out` invocations count as mutations (they write the result to disk). if (browserManager.isWatching() && isWriteInvocation(command, args)) { return { status: 400, json: true, result: JSON.stringify({ error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.' }), }; } // Activity: emit command_start (skipped for chain subcommands) const startTime = Date.now(); if (!opts?.skipActivity) { emitActivity({ type: 'command_start', command, args, url: browserManager.getCurrentUrl(), tabs: browserManager.getTabCount(), mode: browserManager.getConnectionMode(), clientId: tokenInfo?.clientId, }); } try { let result: string; const session = browserManager.getActiveSession(); // Per-request warnings collected during hidden-element detection, // surfaced into the envelope the LLM sees. Carries across the read // phase into the centralized wrap block below. let hiddenContentWarnings: string[] = []; if (READ_COMMANDS.has(command)) { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; // Hidden-element / ARIA-injection detection for every scoped // DOM-reading channel (text, html, links, forms, accessibility, // attrs, data, media, ux-audit). Previously only `text` received // stripping; other channels let hidden injection payloads reach // the LLM despite the envelope wrap. Detections become CONTENT // WARNINGS on the outgoing envelope so the model can see what it // would have otherwise trusted silently. if (isScoped && DOM_CONTENT_COMMANDS.has(command)) { const page = session.getPage(); try { const strippedDescs = await markHiddenElements(page); if (strippedDescs.length > 0) { console.warn(`[browse] Content security: ${strippedDescs.length} hidden elements flagged on ${command} for ${tokenInfo.clientId}`); hiddenContentWarnings = strippedDescs.slice(0, 8).map(d => `hidden content: ${d.slice(0, 120)}`, ); if (strippedDescs.length > 8) { hiddenContentWarnings.push(`hidden content: +${strippedDescs.length - 8} more flagged elements`); } } if (command === 'text') { const target = session.getActiveFrameOrPage(); result = await getCleanTextWithStripping(target); } else { result = await handleReadCommand(command, args, session, browserManager); } } finally { await cleanupHiddenMarkers(page); } } else { result = await handleReadCommand(command, args, session, browserManager); } } else if (WRITE_COMMANDS.has(command)) { result = await handleWriteCommand(command, args, session, browserManager); } else if (META_COMMANDS.has(command)) { // Pass chain depth + executeCommand callback so chain routes subcommands // through the full security pipeline (scope, domain, tab, wrapping). const chainDepth = (opts?.chainDepth ?? 0); // shutdown is factory-scoped (deleted from module scope in v1.35.0.0); // route the call through activeShutdown which buildFetchHandler assigns. const shutdownFn = () => activeShutdown ? activeShutdown() : Promise.resolve(); result = await handleMetaCommand(command, args, browserManager, shutdownFn, tokenInfo, { chainDepth, daemonPort: LOCAL_LISTEN_PORT, executeCommand: (body, ti) => handleCommandInternal(body, ti, { skipRateCheck: true, // chain counts as 1 request skipActivity: true, // chain emits 1 event for all subcommands chainDepth: chainDepth + 1, // recursion guard }), }); // Start periodic snapshot interval when watch mode begins if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) { const watchInterval = setInterval(async () => { if (!browserManager.isWatching()) { clearInterval(watchInterval); return; } try { const snapshot = await handleSnapshot(['-i'], browserManager.getActiveSession()); browserManager.addWatchSnapshot(snapshot); } catch { // Page may be navigating — skip this snapshot } }, 5000); browserManager.watchInterval = watchInterval; } } else if (command === 'help') { const helpText = generateHelpText(); return { status: 200, result: helpText }; } else { // Use the rich unknown-command helper: names the input, suggests the closest // match via Levenshtein (≤ 2 distance, ≥ 4 chars input), and appends an upgrade // hint if the command is listed in NEW_IN_VERSION. return { status: 400, json: true, result: JSON.stringify({ error: buildUnknownCommandError(rawCommand, ALL_COMMANDS), hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, }), }; } // ─── Centralized content wrapping (single location for all commands) ─── // Scoped tokens: content filter + enhanced envelope + datamarking // Root tokens: basic untrusted content wrapper (backward compat) // Chain exempt from top-level wrapping (each subcommand wrapped individually) if (PAGE_CONTENT_COMMANDS.has(command) && command !== 'chain') { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; if (isScoped) { // Run content filters const filterResult: ContentFilterResult = runContentFilters( result, browserManager.getCurrentUrl(), command, ); if (filterResult.blocked) { return { status: 403, json: true, result: JSON.stringify({ error: filterResult.message }) }; } // Datamark text command output only (not html, forms, or structured data) if (command === 'text') { result = datamarkContent(result); } // Enhanced envelope wrapping for scoped tokens. // Merge per-request hidden-element warnings with content-filter // warnings so both reach the LLM through the same CONTENT // WARNINGS header. const combinedWarnings = [...filterResult.warnings, ...hiddenContentWarnings]; result = wrapUntrustedPageContent( result, command, combinedWarnings.length > 0 ? combinedWarnings : undefined, ); } else { // Root token: basic wrapping (backward compat, Decision 2) result = wrapUntrustedContent(result, browserManager.getCurrentUrl()); } } // Activity: emit command_end (skipped for chain subcommands) const successDuration = Date.now() - startTime; if (!opts?.skipActivity) { emitActivity({ type: 'command_end', command, args, url: browserManager.getCurrentUrl(), duration: successDuration, status: 'ok', result: result, tabs: browserManager.getTabCount(), mode: browserManager.getConnectionMode(), clientId: tokenInfo?.clientId, }); } writeAuditEntry({ ts: new Date().toISOString(), cmd: command, aliasOf: isAliased ? rawCommand : undefined, args: args.join(' '), origin: browserManager.getCurrentUrl(), durationMs: successDuration, status: 'ok', hasCookies: browserManager.hasCookieImports(), mode: browserManager.getConnectionMode(), }); browserManager.resetFailures(); // Restore original active tab if we pinned to a specific one if (savedTabId !== null) { try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) { console.warn('[browse] Failed to restore tab after command:', restoreErr.message); } } return { status: 200, result }; } catch (err: any) { // Restore original active tab even on error if (savedTabId !== null) { try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) { console.warn('[browse] Failed to restore tab after error:', restoreErr.message); } } // Activity: emit command_end (error) — skipped for chain subcommands const errorDuration = Date.now() - startTime; if (!opts?.skipActivity) { emitActivity({ type: 'command_end', command, args, url: browserManager.getCurrentUrl(), duration: errorDuration, status: 'error', error: err.message, tabs: browserManager.getTabCount(), mode: browserManager.getConnectionMode(), clientId: tokenInfo?.clientId, }); } writeAuditEntry({ ts: new Date().toISOString(), cmd: command, aliasOf: isAliased ? rawCommand : undefined, args: args.join(' '), origin: browserManager.getCurrentUrl(), durationMs: errorDuration, status: 'error', error: err.message, hasCookies: browserManager.hasCookieImports(), mode: browserManager.getConnectionMode(), }); browserManager.incrementFailures(); let errorMsg = wrapError(err); const hint = browserManager.getFailureHint(); if (hint) errorMsg += '\n' + hint; return { status: 500, result: JSON.stringify({ error: errorMsg }), json: true }; } } /** * Sanitizing wrapper around handleCommandInternalImpl. ALL callers (single-command * HTTP, batch loop, scoped-token dispatch) go through this so the lone-surrogate * sanitization happens once at the architectural choke point, not per-leaf. * Do not bypass this by calling handleCommandInternalImpl directly. */ async function handleCommandInternal( body: { command: string; args?: string[]; tabId?: number }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { const cr = await handleCommandInternalImpl(body, tokenInfo, opts); return { ...cr, result: stripLoneSurrogates(cr.result) }; } /** * Build the HTTP response from a CommandResult. Pure function so it can be * unit-tested without spinning up the server (#1440). Defense in depth on top * of handleCommandInternal's choke-point sanitization: this catches any * \uXXXX JSON-escape surrogate forms that the raw-codepoint regex above * misses when the body has already been JSON-stringified. */ export function buildCommandResponse(cr: CommandResult): Response { const contentType = cr.json ? 'application/json' : 'text/plain'; const safeBody = typeof cr.result === 'string' ? sanitizeBody(cr.result, !!cr.json) : cr.result; return new Response(safeBody, { status: cr.status, headers: { 'Content-Type': contentType, ...cr.headers }, }); } /** HTTP wrapper — converts CommandResult to Response. Used by the /command * route (routes/commands.ts, via RouteContext). The wrapper layer exists so * `buildCommandResponse` is independently unit-testable (v1.38.1.0). */ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise { const cr = await handleCommandInternal(body, tokenInfo); return buildCommandResponse(cr); } // Module-level shutdown function deleted in v1.39.0.0; it now lives inside // the buildFetchHandler closure so it closes the cfg-provided browserManager. // Signal handlers below call activeShutdown which buildFetchHandler assigns. // Handle signals // // Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners. // Wrap calls so activeShutdown receives no args — otherwise the string gets // passed as exitCode and process.exit() coerces it to NaN, exiting with code 1 // instead of 0. (Caught in v0.18.1.0 #1025.) // // Gated on `import.meta.main` so embedders (gbrowser phoenix) that import // server.ts as a submodule can register their own signal handlers without // fighting with gstack's. CLI path is unchanged. if (import.meta.main) { // Standalone daemon: a Chromium crash must exit THIS process (its // supervisor/user notices); embedders and in-process test launches must // never be exited by browser-manager's disconnect handler. markDaemonProcess(); // SIGINT (Ctrl+C): user intentionally stopping → shutdown. process.on('SIGINT', () => activeShutdown?.()); // SIGHUP (terminal hangup): with handleSIGHUP:false at the three launch // sites (#2220), Playwright no longer closes Chromium when this process // gets hung up on — this handler is now the ONLY Chromium cleanup on // SIGHUP (ENG-OV4). Route to the same shutdown path as SIGINT: // activeShutdown closes the browser, releases ports, and removes the // state file. Without it, a hangup would leak a live Chromium. process.on('SIGHUP', () => activeShutdown?.()); // SIGTERM behavior depends on mode: // - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the // parent shell exits between tool invocations. Ignoring it keeps the server // alive across $B calls. Idle timeout (30 min) handles eventual cleanup. // - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect // SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly // without waiting forever. Ctrl+C and /stop still work either way. // - Active cookie picker: never tear down mid-import regardless of mode — // would strand the picker UI with "Failed to fetch." process.on('SIGTERM', () => { if (hasActivePicker()) { console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI'); return; } const headed = activeBrowserManager.getConnectionMode() === 'headed'; if (headed || tunnelActive) { console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`); activeShutdown?.(); } else { console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)'); } }); // Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths. // Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check. if (process.platform === 'win32') { process.on('exit', removeOwnedDaemonStateQuiet); } } // Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect) function emergencyCleanup() { if (isShuttingDown) return; isShuttingDown = true; // Xvfb cleanup MUST happen before state-file deletion. spawnXvfb detaches // the child, so without this, an uncaught exception leaves the Xvfb // running with no PID record — orphan accumulates and eventually // exhausts the :99-:120 display range. Read the state file FIRST, // call cleanupXvfb (validates cmdline + start-time before kill), THEN // delete the state file. try { if (fs.existsSync(config.stateFile)) { const raw = fs.readFileSync(config.stateFile, 'utf-8'); const state = JSON.parse(raw); if (state.pid !== process.pid || state.instanceId !== SERVER_INSTANCE_ID) return; if (state.xvfbPid && state.xvfbStartTime) { // Lazy import — emergencyCleanup may run on platforms where // ./xvfb's Linux-specific helpers fail to load. Best effort. try { const { cleanupXvfb } = require('./xvfb'); cleanupXvfb({ pid: state.xvfbPid, startTime: state.xvfbStartTime, display: state.xvfbDisplay || ':99', }); } catch { /* best effort */ } } } } catch { /* state file unparseable — fall through to lock + state cleanup */ } // Clean Chromium profile locks via the shared helper (defensive guard // refuses to operate on unrecognized profile dirs). if (activeBrowserManager.getConnectionMode() === 'headed' || process.env.BROWSE_HEADED === '1') { cleanSingletonLocks(resolveChromiumProfile()); } removeOwnedDaemonStateQuiet(); } // Same import.meta.main gate as SIGINT/SIGTERM — embedders register their // own crash handlers. if (import.meta.main) { process.on('uncaughtException', (err) => { console.error('[browse] FATAL uncaught exception:', err.message); emergencyCleanup(); process.exit(1); }); process.on('unhandledRejection', (err: any) => { console.error('[browse] FATAL unhandled rejection:', err?.message || err); emergencyCleanup(); process.exit(1); }); } // ─── Start ───────────────────────────────────────────────────── /** * Entry point for `bun run dev` and the compiled binary. * * Exported so embedders (gbrowser phoenix overlay) can call it * directly with env vars set, bypassing the module-level `import.meta.main` * gate. Phoenix's eventual fd-passing path will use `buildFetchHandler` * directly; until that lands, calling `start()` from a non-main entry is * supported via env (AUTH_TOKEN, BROWSE_PORT, BROWSE_OWN_SIGNALS). */ /** * 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. * * Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the * single source of truth for the bearer secret, factory-scoped validateAuth * closes over it, and factory-scoped shutdown closes the cfg-provided * browserManager. Module-level lifecycle singletons (LOCAL_LISTEN_PORT, * tunnelActive, inspector state) intentionally STAY at module scope; see * the v1.35.0.0 CHANGELOG entry for the architectural rationale. * * The returned ServerHandle is callable directly. Bun.serve is the caller's * responsibility — embedders may fd-pass; CLI uses Bun.serve normally. */ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { if (!cfg.authToken || cfg.authToken.length < 16) { throw new Error('buildFetchHandler: cfg.authToken must be a non-empty string >= 16 chars'); } if (!cfg.browserManager) { throw new Error('buildFetchHandler: cfg.browserManager is required'); } // Re-run init with cfg-provided values. ensureStateDir is idempotent // (mkdir -p); initAuditLog is idempotent (sets a module string); // initRegistry is idempotent for same-token, throws for different-token. // Owning init here (instead of at module load) means cfg.authToken is the // single source of truth for the registry root token. ensureStateDir(cfg.config); initAuditLog(cfg.config.auditLog); 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_MAX = 3; // The guard window MUST span enough ticks for RESPAWN_GUARD_MAX respawns to // land inside it. This was a fixed 60_000 against a 60_000 tick, so at most // ONE respawn could ever be in the window and `respawnHistory.length >= 3` // was unreachable — the guard could not fire at the default tick rate, and a // steady one-per-tick leak ran unbounded instead of stopping after 3. Scale // with the tick so the intent ("3 crashes in quick succession → stop") holds // at any tick value: 3 respawns within 5 ticks trips it. const RESPAWN_GUARD_WINDOW_MS = Math.max( 60_000, AGENT_WATCHDOG_TICK_MS * (RESPAWN_GUARD_MAX + 2), ); let agentRespawnGuardTripped = false; let consecutiveSpawnFailures = 0; 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 && !isAgentRecordGone(record)) 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, ownerPid: process.pid, cwd: cfg.config.projectDir, }); if (pid) { consecutiveSpawnFailures = 0; console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`); } else { consecutiveSpawnFailures++; console.warn('[browse] terminal-agent respawn skipped — script not found on disk'); } } catch (err: any) { consecutiveSpawnFailures++; console.warn('[browse] terminal-agent respawn failed:', err?.message || err); } if (consecutiveSpawnFailures >= RESPAWN_GUARD_MAX) { agentRespawnGuardTripped = true; console.error('[browse] terminal-agent respawn guard tripped after repeated failed starts — manual restart required'); } }, 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 // validateAuth was deleted in v1.35.0.0. function validateAuth(req: Request): boolean { const header = req.headers.get('authorization'); if (header === null) return false; // Constant-time compare so a byte-by-byte early-exit can't leak the token // prefix via response timing. timingSafeEqual requires equal-length inputs, // so the length check gates it (the length itself is not secret). const got = Buffer.from(header); const want = Buffer.from(`Bearer ${authToken}`); return got.length === want.length && crypto.timingSafeEqual(got, want); } // Factory-scoped shutdown. Closes the cfg-provided browserManager so // embedders that pass their own BrowserManager get correct teardown. // Module-level shutdown was deleted in v1.35.0.0. async function shutdown(exitCode: number = 0) { if (isShuttingDown) return; isShuttingDown = true; // State and terminal files belong to this instance, including embedders // whose cfg differs from the standalone daemon's module-level config. const config = cfg.config; const stateOwner = (() => { try { return JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch { return null; } })(); const foreignState = Number.isSafeInteger(stateOwner?.pid) && stateOwner.pid > 0 && (stateOwner.pid !== process.pid || (stateOwner.instanceId && stateOwner.instanceId !== SERVER_INSTANCE_ID)); console.log('[browse] Shutting down...'); if (ownsTerminalAgent && !foreignState) { // 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 stateDir = path.dirname(config.stateFile); const releaseAgentLock = acquireAgentStateLock(stateDir); try { let currentState: { pid?: number; instanceId?: string } | null = null; try { currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {} if (currentState?.pid && (currentState.pid !== process.pid || (currentState.instanceId && currentState.instanceId !== SERVER_INSTANCE_ID))) { console.warn('[browse] terminal-agent state now belongs to a successor; retaining its files'); } else { const record = readAgentRecord(stateDir); const agentStopped = !record || (record.pid !== 0 && (!isAgentRecordLive(record) || (isOurAgent(record, process.pid) && stopAgentByRecord(record)))); const current = readAgentRecord(stateDir); if (agentStopped && (!record || (current?.pid === record.pid && current.gen === record.gen))) { safeUnlinkQuiet(path.join(stateDir, 'terminal-port')); safeUnlinkQuiet(path.join(stateDir, 'terminal-internal-token')); if (record) clearAgentRecord(stateDir, record); } else if (!agentStopped) { console.warn('[browse] terminal-agent identity or exit could not be confirmed; retaining its record'); } } } finally { releaseAgentLock(); } } catch (err: any) { console.warn('[browse] Failed to stop terminal-agent; retaining its state:', err.message); } } try { detachSession(); } catch (err: any) { console.warn('[browse] Failed to detach CDP session:', err.message); } clearInspectorSubscribers(); if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch(); clearInterval(flushInterval); clearInterval(idleCheckInterval); if (agentWatchdogInterval) clearInterval(agentWatchdogInterval); // Stop the session-persist ticker BEFORE the final snapshot below — // paired with the isShuttingDown gate inside the tick, this guarantees // no interval snapshot can race the final one during teardown. if (sessionPersistInterval) { clearInterval(sessionPersistInterval); sessionPersistInterval = null; } await flushBuffers(); // Final session snapshot before the browser goes away (#778). Best // effort with a hard 2s deadline: shutdown must never hang on a wedged // page.evaluate — after the deadline we proceed to browser close and let // the previous interval snapshot stand (atomic writes guarantee it's // intact). The .catch is attached to the persist promise itself so a // late rejection after losing the race can't become an unhandled // rejection. if (isSessionPersistEnabled()) { const finalSnapshot = persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE)) .catch((err: any) => { console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`); }); await Promise.race([ finalSnapshot, new Promise((resolve) => setTimeout(resolve, 2_000)), ]); } await cfgBrowserManager.close(); if (cfgBrowserManager.getConnectionMode() === 'headed') { cleanSingletonLocks(resolveChromiumProfile()); } if (!foreignState) { try { const releaseStateLock = acquireAgentStateLock(path.dirname(config.stateFile)); try { const currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); if (currentState.pid === process.pid && currentState.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile); } finally { releaseStateLock(); } } catch (err: any) { if (fs.existsSync(config.stateFile)) console.warn('[browse] Daemon state cleanup could not confirm ownership:', err?.message || err); } } process.exit(exitCode); } // Named lifecycle helper (matches closeTunnel style). Logs failures so // future debugging isn't blind to a stuck listener. async function stopListeners(local: any, tunnel?: any) { try { if (local?.stop) local.stop(true); } catch (err: any) { console.warn('[browse] local listener stop failed:', err?.message || err); } try { if (tunnel?.stop) tunnel.stop(true); } catch (err: any) { console.warn('[browse] tunnel listener stop failed:', err?.message || err); } } // Register this handle's shutdown as the active one. Module-level // handlers (idleCheckInterval, parent watchdog, onDisconnect, signal // handlers) call activeShutdown so they reach THIS shutdown, not a stale // module reference. Critical for embedders whose cfg.browserManager // differs from the module-level instance. activeShutdown = shutdown; // Retarget the BrowserManager indirection at the cfg-instance so the // module-level idleCheckTick + parent watchdog + SIGTERM handler all read // the right connectionMode. Without this, headed embedders auto-shutdown // after 30 min of HTTP idle because the dead module-level instance still // reports connectionMode === 'launched'. activeBrowserManager = cfgBrowserManager; // Same reason as above: the watchdog reads activeBrowserManager, so the // instance that can promote itself to headed must be the one that can // suppress the headed parent-death branch. An embedder-supplied manager // otherwise promotes silently and the watchdog keeps shutting down on a // promotion it can no longer see. cfgBrowserManager.onHeadedPromotion = () => suppressHeadedParentShutdown(cfg.config, cfgBrowserManager); // Wire the cfg-instance's onDisconnect to run shutdown when the user // closes the headed browser window. CHAIN any caller-provided handler // instead of overwriting it: gbrowser may have set its own onDisconnect // before calling buildFetchHandler (e.g. for snapshot/log work that needs // to run before the process exits). Caller errors are logged but never // block gstack shutdown — defensive symmetry with the safeUnlinkQuiet / // safeKill philosophy in error-handling.ts. const callerOnDisconnect = cfgBrowserManager.onDisconnect; cfgBrowserManager.onDisconnect = async (code) => { if (callerOnDisconnect) { try { await callerOnDisconnect(code); } catch (err: any) { console.warn('[browse] caller onDisconnect threw:', err?.message ?? err); } } await activeShutdown?.(code ?? 2); }; // Everything a route handler may use. Handlers get the cfg-provided // BrowserManager and auth checks as functions; the raw token reaches only // the two bootstrap routes that hand it on (see RouteContext). const routeCtx: RouteContext = { browserManager: cfgBrowserManager, startTime, browsePort, validateAuth, isRootRequest, getTokenInfo, hasSseCookie: (req) => validateSseSessionToken(extractSseCookie(req)), isPinnedExtensionRequest, isRootTokenValue: (token) => token !== null && token === authToken, bootstrapRootToken: authToken, resetIdleTimer, terminal: { readPort: readTerminalPort, grantToken: grantPtyToken, restartSession: restartPtySession }, tunnel: { state: () => ({ active: tunnelActive, url: tunnelUrl, hasListener: tunnelServer !== null }), close: closeTunnel, resolveAuthtoken: resolveNgrokAuthtoken, start: (authtoken) => startTunnel({ fetchHandler: makeFetchHandler('tunnel'), authtoken, consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)', }), }, commands: { handle: handleCommand, handleInternal: (body, tokenInfo, opts) => handleCommandInternal(body, tokenInfo, opts), }, }; const makeFetchHandler = (surface: Surface) => async (req: Request): Promise => { const url = new URL(req.url); // ─── Tunnel surface filter (runs before any route dispatch) ── if (surface === 'tunnel') { const isGetConnect = req.method === 'GET' && url.pathname === '/connect'; const allowed = TUNNEL_PATHS.has(url.pathname); if (!allowed && !isGetConnect) { logTunnelDenial(req, url, 'path_not_on_tunnel'); return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } if (isRootRequest(req)) { logTunnelDenial(req, url, 'root_token_on_tunnel'); return new Response(JSON.stringify({ error: 'Root token rejected on tunnel surface', hint: 'Remote agents must pair via /connect to receive a scoped token.', }), { status: 403, headers: { 'Content-Type': 'application/json' } }); } if (url.pathname !== '/connect' && !getTokenInfo(req)) { logTunnelDenial(req, url, 'missing_scoped_token'); return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } } // beforeRoute overlay hook (v1.35.0.0). Runs AFTER the tunnel surface // filter and BEFORE per-route dispatch. Pre-resolves bearer auth once // so the hook receives TokenInfo | null. Note: getTokenInfo returns null // for both missing AND invalid bearer — see the ServerConfig.beforeRoute // JSDoc for the security implications. if (beforeRoute) { const auth = getTokenInfo(req); const overlayResp = await beforeRoute(req, surface, auth); if (overlayResp) return overlayResp; } return dispatchRoute(ROUTES, req, url, surface, routeCtx); }; return { fetchLocal: makeFetchHandler('local'), fetchTunnel: makeFetchHandler('tunnel'), shutdown, stopListeners, }; } export async function start() { // Clear old log files safeUnlink(CONSOLE_LOG_PATH); safeUnlink(NETWORK_LOG_PATH); safeUnlink(DIALOG_LOG_PATH); const port = await findPort(); LOCAL_LISTEN_PORT = port; // ─── Proxy config (D8 + codex F5) ────────────────────────────── // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 // with auth, we run a local 127.0.0.1 bridge that relays to the // authenticated upstream (Chromium can't do SOCKS5 auth itself). For // HTTP/HTTPS or unauthenticated SOCKS5, we pass the URL directly to // Chromium's proxy.server option. let proxyBridge: BridgeHandle | null = null; const proxyUrl = process.env.BROWSE_PROXY_URL; if (proxyUrl) { let parsed; try { parsed = parseProxyConfig({ proxyUrl, envUser: process.env.BROWSE_PROXY_USER, envPass: process.env.BROWSE_PROXY_PASS, }); } catch (err) { if (err instanceof ProxyConfigError) { console.error(`[browse] error: ${err.message} (${err.hint})`); process.exit(1); } throw err; } if (parsed.scheme === 'socks5' && parsed.hasAuth) { // Pre-flight: verify upstream accepts our creds before launching // Chromium. 5s budget, 3 retries with 500ms backoff (D4: handles VPN // warm-up race). On failure, exit with redacted error. console.log(`[browse] Testing SOCKS5 upstream ${redactProxyUrl(proxyUrl)}...`); try { const test = await testUpstream({ upstream: toUpstreamConfig(parsed), budgetMs: 5000, retries: 3, backoffMs: 500, }); console.log(`[browse] [proxy] upstream test ok in ${test.ms}ms (${test.attempts} attempt${test.attempts === 1 ? '' : 's'})`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[browse] [proxy] FAIL upstream ${redactProxyUrl(proxyUrl)}: ${msg}`); process.exit(1); } proxyBridge = await startSocksBridge({ upstream: toUpstreamConfig(parsed) }); console.log(`[browse] [proxy] bridge listening on 127.0.0.1:${proxyBridge.port}`); browserManager.setProxyConfig({ server: `socks5://127.0.0.1:${proxyBridge.port}` }); } else { // HTTP/HTTPS or unauth SOCKS5 — pass through to Chromium directly. browserManager.setProxyConfig({ server: `${parsed.scheme}://${parsed.host}:${parsed.port}`, ...(parsed.userId ? { username: parsed.userId } : {}), ...(parsed.password ? { password: parsed.password } : {}), }); console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} (pass-through to Chromium)`); } // Tear down bridge on shutdown. process.on('exit', () => { if (proxyBridge) { proxyBridge.close().catch(() => { /* shutting down anyway */ }); } }); } process.on('exit', () => { browserManager.closeOwnedDisplay(); }); // Read env once — single source of truth for authToken (and other env). // Threaded through launchHeaded, buildFetchHandler, and the state file // write so all consumers see the same value. v1.34.x's module-level // AUTH_TOKEN const was deleted in v1.35.0.0. const envCfg = resolveConfigFromEnv(); // Launch browser (headless or headed with extension) // BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing) const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1'; if (!skipBrowser) { const headed = process.env.BROWSE_HEADED === '1'; if (headed) { await browserManager.launchHeaded(envCfg.authToken); console.log(`[browse] Launched headed Chromium with extension`); } else { await browserManager.launch(); } } const startTime = Date.now(); // ─── Build the request handlers via buildFetchHandler factory ─── // CLI path passes env-derived values; no beforeRoute hook. Phoenix uses // the same factory with its own cfg + overlay hook. const handle = buildFetchHandler({ ...envCfg, browsePort: port, // actual bound port (resolveConfigFromEnv default is 0) browserManager, // module-level instance, same as today proxyBridge, startTime, ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063) }); const server = Bun.serve({ port, hostname: '127.0.0.1', fetch: handle.fetchLocal, }); browserManager.serverPort = port; // Navigate to welcome page if in headed mode and still on about:blank if (browserManager.getConnectionMode() === 'headed') { try { const currentUrl = browserManager.getCurrentUrl(); if (currentUrl === 'about:blank' || currentUrl === '') { const page = browserManager.getPage(); await page.goto(`http://127.0.0.1:${port}/welcome`, { timeout: 3000 }).catch((err: any) => { console.warn('[browse] Failed to navigate to welcome page:', err.message); }); } } catch (err: any) { console.warn('[browse] Welcome page navigation setup failed:', err.message); } } if (isShuttingDown) return; // Write state file (atomic: write .tmp then rename) const xvfb = browserManager.getXvfbHandle(); const state: Record = { pid: process.pid, instanceId: SERVER_INSTANCE_ID, port, token: envCfg.authToken, startedAt: new Date().toISOString(), serverPath: path.resolve(import.meta.dir, 'server.ts'), binaryVersion: readVersionHash() || undefined, mode: browserManager.getConnectionMode(), // D2 daemon-mismatch detection: CLI computes the same hash from its // resolved flags and refuses if it differs from this stored value. ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), // Xvfb child PID + start-time + display so disconnect (or a future // daemon launch on this state file) can validate-then-cleanup orphans // without clobbering a recycled PID. ...(xvfb ? { xvfbPid: xvfb.pid, xvfbStartTime: xvfb.startTime, xvfbDisplay: xvfb.display } : {}), // #2709: launched-Chromium identity (pid + start time) so `browse stop` // can reap a survivor — the headless launch has no SingletonLock for // killOrphanChromium to walk, and on macOS 26 the orphaned GPU process // kept spinning at ~800% CPU after the daemon exited. ...(() => { const info = browserManager.getChromiumProcInfo(); return info ? { chromiumPid: info.pid, chromiumStartTime: info.startTime } : {}; })(), }; const tmpFile = tmpStatePath(); fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); try { const releaseStateLock = acquireAgentStateLock(config.stateDir); try { fs.renameSync(tmpFile, config.stateFile); } finally { releaseStateLock(); } } catch (err) { safeUnlinkQuiet(tmpFile); throw err; } const stateWatchMs = parseInt(process.env.GSTACK_STATE_WATCH_MS || '60000', 10); if (stateWatchMs > 0) { let missed = 0; const stateWatch = setInterval(() => { let owner: { pid?: number; instanceId?: string } | null = null; try { owner = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {} if (owner?.pid === process.pid && owner.instanceId === SERVER_INSTANCE_ID) { missed = 0; return; } if (++missed < 2) return; clearInterval(stateWatch); console.warn('[browse] daemon state is no longer reachable; shutting down this instance'); handle.shutdown(); }, stateWatchMs); (stateWatch as any).unref?.(); } // ─── Opt-in session persistence (#778 class) ───────────────── // BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last // snapshot, then keep snapshotting on an interval. Launched mode only — // the headed persistent profile owns its own state. The final snapshot at // clean shutdown lives in buildFetchHandler's shutdown(). // // Runs AFTER Bun.serve() + the state-file write, in the BACKGROUND: // restore re-creates tabs sequentially with up-to-15s goto timeouts while // the CLI's readiness probe gives up at 8s — one slow/unreachable saved // URL must never make every `$B` command report "Server failed to start". // Fire-and-forget: a restore failure is logged and never affects the // daemon. if (!skipBrowser && isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') { const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE); restoreSessionState(browserManager, sessionStatePath) .then((restored) => { if (restored) { // Counts come from the deserialized snapshot itself — no extra // saveState() round-trip against pages that may still be loading. console.log(`[browse] Session state restored: ${restored.cookies.length} cookies / ${restored.pages.length} tabs (BROWSE_PERSIST_STATE=1)`); } else { console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)'); } }) .catch((err: any) => { console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`); }); let persistWarned = false; // In-flight guard: never start a new snapshot while the previous one is // still pending (a slow page.evaluate would otherwise pile up ticks). let persistInFlight = false; sessionPersistInterval = setInterval(() => { // Shutdown gate (belt; shutdown()'s clearInterval is the suspenders): // a tick that fires during browser teardown snapshots a degraded state // (zero tabs) over the good final snapshot. if (isShuttingDown) return; if (persistInFlight) return; // skip the tick persistInFlight = true; persistSessionState(browserManager, sessionStatePath) .catch((err: any) => { // Warn once — a full disk must not spam the log every 30s, and a // snapshot failure must never kill the daemon (R3). if (!persistWarned) { persistWarned = true; console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`); } }) .finally(() => { persistInFlight = false; }); }, sessionPersistIntervalMs()); (sessionPersistInterval as any)?.unref?.(); } // Clean up stale state files (older than 7 days) try { const stateDir = path.join(config.stateDir, 'browse-states'); if (fs.existsSync(stateDir)) { const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; for (const file of fs.readdirSync(stateDir)) { const filePath = path.join(stateDir, file); const stat = fs.statSync(filePath); if (Date.now() - stat.mtimeMs > SEVEN_DAYS) { fs.unlinkSync(filePath); console.log(`[browse] Deleted stale state file: ${file}`); } } } } catch (err: any) { console.warn('[browse] Failed to clean stale state files:', err.message); } console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); console.log(`[browse] State file: ${config.stateFile}`); console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); // ─── Tunnel startup (optional) ──────────────────────────────── // Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener // pattern: bind a dedicated tunnel listener on an ephemeral port and // point ngrok.forward() at IT, not the local daemon port. if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) { console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on'); } else if (process.env.BROWSE_TUNNEL === '1') { const authtoken = resolveNgrokAuthtoken(); if (!authtoken) { console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env'); } else { // Shared startTunnel helper: binds the tunnel listener, opens ngrok, // and on any failure tears down BOTH ngrok and the Bun listener so we // don't leak an ngrok session if the error happened after // ngrok.forward() resolved. const started = await startTunnel({ fetchHandler: handle.fetchTunnel, authtoken, consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)', }); if (!started.ok) { console.error(`[browse] Failed to start tunnel: ${started.error.message}`); } } } else if (process.env.BROWSE_TUNNEL_LOCAL_ONLY === '1') { // Test-only: bind the dual-listener tunnel surface on 127.0.0.1 with NO // ngrok forwarding. Lets paid evals exercise the surface==='tunnel' gate // without an ngrok authtoken or live network. Production tunneling still // requires BROWSE_TUNNEL=1 + a valid authtoken above. try { const boundTunnel = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: handle.fetchTunnel, }); tunnelServer = boundTunnel; tunnelActive = true; const tunnelPort = boundTunnel.port; console.log(`[browse] Tunnel listener bound (local-only test mode) on 127.0.0.1:${tunnelPort}`); const releaseStateLock = acquireAgentStateLock(config.stateDir); try { const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced'); stateContent.tunnelLocalPort = tunnelPort; const tmpState = tmpStatePath(); fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); fs.renameSync(tmpState, config.stateFile); } finally { releaseStateLock(); } } catch (err: any) { console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`); } } } /** * Test-only. Resets the module-level shutdown latch so a second test case * can exercise shutdown() in the same process. Mirrors __resetRegistry in * token-registry.ts. shutdown() short-circuits when isShuttingDown is true * (see line near the start of shutdown), so without this, tests that call * shutdown() more than once silently no-op after the first call. * * DO NOT call from production code. Defeats the shutdown re-entry guard, * which can race process.exit with cfgBrowserManager.close() and the pkill / * safeUnlinkQuiet side effects. The `__` prefix is the convention; nothing * enforces it. If you find yourself reaching for this outside a test file, * the right fix is to make isShuttingDown factory-scoped instead. */ export function __resetShuttingDown(): void { isShuttingDown = false; } // Auto-kickoff only when this module is the entry point. Embedders // (gbrowser phoenix overlay) import { start, buildFetchHandler, ... } // without triggering the listener-binding side effects. if (import.meta.main) { start().catch((err) => { console.error(`[browse] Failed to start: ${err.message}`); // Write error to disk for the CLI to read — on Windows, the CLI can't capture // stderr because the server is launched with detached: true, stdio: 'ignore'. try { const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); mkdirSecure(config.stateDir); writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); } catch { // stateDir may not exist — nothing more we can do } process.exit(1); }); }