Files
gstack/browse/src/server.ts
T
garrytanandGabriel Rondon b34cc829ed D2: one address classifier for navigation literals, DNS answers, redirects and page-driven navigations (#2811)
classifyAddress() in url-validation.ts blocks 169.254.0.0/16 (metadata and
container credential endpoints), 100.100.100.200, fe80::/10 and fc00::/7,
including IPv4-mapped, IPv4-compatible and NAT64 spellings and decimal,
octal and hex IPv4. validateNavigationUrl, the A/AAAA answer check and
session-persist's cookie-domain guard all use it. BrowserManager now checks
every navigation request (redirect hops, link clicks, scripted navigations):
a blocked target resets the tab to about:blank and the write command fails
with the reason. Connection-time DNS rebinding remains out of scope.

Co-authored-by: Gabriel Rondon <grondon@gmail.com>
2026-10-03 20:46:00 +00:00

2222 lines
102 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
* Log files: <project-root>/.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 { allowedExtensionOrigin } from './extension-id';
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<void>) | 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<typeof Bun.serve> | 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<typeof resolveConfig>;
/** 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<Response | null>;
/**
* Whether gstack owns the lifecycle of the terminal-agent process and its
* discovery files (`<stateDir>/terminal-port`, `<stateDir>/terminal-internal-token`,
* `<stateDir>/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(<stateDir>/terminal-port)`
* 3. `safeUnlinkQuiet(<stateDir>/terminal-internal-token)`
* 4. `safeUnlinkQuiet(<stateDir>/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<Response>;
fetchTunnel: (req: Request, server: any) => Promise<Response>;
/**
* 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<void>;
/**
* 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<void>;
}
/**
* 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<ServerConfig, 'browserManager' | 'startTime'> & {
config: ReturnType<typeof resolveConfig>;
} {
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<string>([
'/connect',
'/command',
]);
/**
* POST /extension-token releases AUTH_TOKEN only to an Origin of exactly the
* pinned extension, or the one set with `gstack-config set browse_extension_id`
* (browse/src/extension-id.ts, shared with the terminal agent's /ws gate).
*/
export { GSTACK_EXTENSION_ID } from './extension-id';
/**
* 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') === allowedExtensionOrigin();
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<void> {
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<Response>;
authtoken: string;
consent: string;
}): Promise<StartTunnelResult> {
// Bind the tunnel listener on an ephemeral port. HARD FAIL if this
// errors — never fall back to the local port.
let boundTunnel: ReturnType<typeof Bun.serve>;
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
* `<stateDir>/terminal-port` and the loopback handshake token to
* `<stateDir>/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<boolean> {
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<boolean> {
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<string, string[]>();
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<string>,
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<typeof setInterval> | 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<number> {
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<string, string>;
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<CommandResult> {
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 browserManager.failIfNavigationBlocked(session.getPage(), 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<CommandResult> {
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<Response> {
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<typeof setInterval> | 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
// `<stateDir>/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<void>((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<Response> => {
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<string, unknown> = {
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);
});
}