mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8) BROWSE_PROXY_URL=direct only stopped gstack from configuring a proxy; the daemon inherits the environment and the launch passed no proxy switch, so Chromium read HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself and every goto still went through that proxy while browse reported a direct connection. Every launch path now adds --proxy-server=direct:// when no proxy is configured (chrome-headless-shell ignores --no-proxy-server), the daemon logs the effective proxy mode on launch, and BROWSER.md's proxy section says what Chromium actually gets. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Name the direct-connection switch once (DIRECT_PROXY_ARG in proxy-config.ts) so the launch argument and the daemon log line cannot drift apart. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): force direct:// only where Chromium would pick its own proxy; review fixes Red team (ship Step 9.2): the first cut passed --proxy-server=direct:// on every platform, but Chromium reads HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself only on Linux. On macOS and Windows it follows the system proxy settings (PAC/WPAD), which GStack Browser.app users behind a corporate proxy depend on, and BROWSE_PROXY_URL cannot name a PAC. forcesDirect(env, platform) now gates the switch: Linux always, elsewhere only with BROWSE_PROXY_URL=direct. The daemon log says what is really ignored per platform, or that the system settings apply. Review pass 2: the CLI e2e asserts on the daemon-log slice its own invocation wrote (the log is append-mode and shared across cases); connectProxy moves to browse/test/helpers/connect-proxy.ts for both e2e files; the CHANGELOG Tests bullets cover the generated cases. Adds the coverage-audit cases (unparseable HTTPS_PROXY; extension, headed and handoff paths under Xvfb). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): assert the direct switch on the launched Chromium command line per platform (#3440 item 8) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Ship review pass 3 (red team, GStack Minder's decision B, 2026-10-26): on macOS and Windows an explicit BROWSE_PROXY_URL=direct is the only thing that makes the daemon bypass the system proxy settings, so explicitDirect() puts it in the daemon config hash, server.ts records it as proxyDirect in the state file, and buildRestartEnv re-emits it on a crash auto-restart. A reused daemon started on the system proxy is now refused (the refusal names BROWSE_PROXY_URL and offers browse stop or unsetting it) instead of silently keeping the system proxy while the CLI reports direct. Linux hashes as before. BROWSER.md says the daemon log records the mode each time the daemon starts, not on every launch. The Linux desktop (GNOME/KDE) system-proxy question is filed as #3450. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): daemon-state and refusal e2e for the explicit direct opt-out; headed env and BROWSER.md contract checks (#3440 item 8) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Ship rerun review pass: proxy-direct-launch-args.test.ts joins the macOS named-regressions job so its omission arm (no switch without the opt-out) runs in CI; serverProxyEnv takes the platform so the state-file predicate is pinned per platform instead of mirroring process.platform; buildHeadedServerEnv's parameter type requires explicitDirect so a narrowed caller cannot drop the opt-out silently; BROWSER.md's precedence item 6 and the reuse sentence say what each platform does (a connect daemon gets the generic flags refusal); CHANGELOG notes the one-time refusal against a pre-release daemon. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Ship adversarial pass: a crash restart re-emits BROWSE_PROXY_URL=direct from the daemon state only when the invocation has no flags, so the env it hands the new daemon never contradicts the config hash it also hands over; an explicit-direct session is refused against a daemon with no recorded config hash, like --proxy and --headed already were; the unparseable-variable hint says that the opt-out also bypasses the system proxy settings on macOS and Windows; BROWSER.md notes that a managed Chromium proxy policy still overrides the switch; CHANGELOG names both files that joined the macOS named-regressions job. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): read scheme-less proxy variables as http:// so the explicit direct launch keeps those sessions proxied (#3440 item 8) A standard proxy variable without a scheme (curl-style host:port or user:pass@host:port) was rejected by browse's parser with a "connects directly" warning while Chromium on Linux read the variable itself, so the session was proxied anyway. With the launch now explicit (--proxy-server=direct://) that environment went genuinely direct. resolveProxy now reads such values as http://, the way curl and Chromium's own environment reader take them; --proxy and BROWSE_PROXY_URL still require the scheme, and a value that does not parse even as http:// still warns and connects directly. Also drops buildRestartEnv's persisted-state re-emit branch, which production never reached (_globalFlags is set before any restart), names the four BrowserManager launch sites in the CHANGELOG and excludes the cookie-import worker, and carries the docs audit's one-line BROWSER.md project-tree note. Decision: GStack Minder (2026-10-26), option A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes The lenient scheme-less proxy read is now gated on the plain [user:pass@]host[:port] shape (SCHEMELESS_PROXY_RE, trimmed): a scheme'd value with surrounding whitespace, a one-slash typo and a bare number keep the behaviour they had, and a credential that would spill past the userinfo (?, #, /) is not rewritten, so nothing can land outside the redacted part. Credentials that are not valid percent-encoding are a ProxyConfigError for every source instead of a URIError crash. buildRestartEnv's docblock, BROWSER.md and the CHANGELOG describe the parse (port 80 default, macOS/Windows now treat such a value as a configured proxy). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes A backslash is excluded from the scheme-less proxy shape and parseProxyConfig refuses any proxy URL with a path, query or fragment, for every source: WHATWG parsing ends an http authority at a backslash, so a DOMAIN\user login or a / in a password landed in the path, which the redactor does not mask, and was printed in the clear on the start line, in the daemon log and in the state file. Such values now warn (standard variables) or error (--proxy, BROWSE_PROXY_URL) without echoing the credential. Docs updated. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): keep a slash-less scheme spelling, refuse a bare ? or #, and tighten the proxy docs A standard proxy variable whose userinfo opens with a known scheme (https:bob:pw@host:3128, which the URL parser reads as https://) is no longer taken as scheme-less credentials, so the protocol it names is kept. parseProxyConfig refuses a literal ? or # anywhere in the value, not only a non-empty query or fragment. The unparseable-variable test now puts the credential in every looped value, so its no-echo assertion is real; new cases pin the slash-less scheme spelling, the bare ?/#, an env-sourced trailing slash, and that mixed credential sources stay a hard error for the scheme-less spelling as for the http:// one (the cred-mixing refusal predates this branch and its test on main pins it, so it is documented rather than softened). CHANGELOG and BROWSER.md: single backslashes inside code spans, "a path other than a bare /", the socks5 bridge log lines in the effective-mode list, and the character list applies anywhere in the value. Decision: GStack Minder (2026-10-26), option B. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(browse): keep a basic-auth URL out of the scheme-guard comment so the pregate secret scan stays clean Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): pin the mismatch remedy per platform so the Windows subset does not take process.platform proxyMismatchLines defaulted to process.platform in the env-proxy-meets-direct-daemon case, so on the windows-free-tests lane the remedy read 'or unset HTTPS_PROXY' and the assertion failed. The case now passes 'linux' explicitly and pins the darwin/win32 remedy beside it. Test only; source unchanged since1f6c85af. Decision: GStack Minder (2026-10-26), option A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(browse): the one-slash http:/host:3128 spelling was never a diagnostic; say what the parser does with it The CHANGELOG sentence and the SCHEMELESS_PROXY_RE docblock claimed a one-slash scheme 'keeps its diagnostic'; the URL parser has always read it as http://host:3128/ and browse/test/proxy-env.test.ts:83 pins that. Wording only, found by the Step 14.5 docs audit on1f6c85af. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(browse): percent-encoding advice per scheme, and state the credential-free-path narrowing and the new scheme-less cred-mix refusal BROWSER.md said to percent-encode / \ ? # @ in http(s) credentials; ? and # are refused for every scheme, @ needs no encoding, and only the backslash is http(s)-specific. The CHANGELOG now states the narrowing that a credential-free standard-variable value with a path (http://proxy.corp:3128/pac) used to be read with the path dropped and now warns and goes direct, and that the cred-mix refusal is new for the scheme-less spelling. Wording only. Decision: GStack Minder (2026-10-26), option A. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(browse): only host:port was Chromium's own reading; user:pass@host:port is newly proxied Chromium's proxy spec parser (net/base/proxy_string_util.cc, ProxySchemeHostAndPortToProxyServer) returns an invalid server for any userinfo, so a scheme-less user:pass@host:port in HTTPS_PROXY was never honoured by Chromium on Linux and went direct; reading it as http:// follows curl and is new behaviour. BROWSER.md, the CHANGELOG, the resolveProxy docblock and the Linux e2e test comment now say so, and the scheme-less spill test is named for what it proves (those values never reach the path refusal; the http:// cases in the next test do). Wording only. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2224 lines
102 KiB
TypeScript
2224 lines
102 KiB
TypeScript
/**
|
||
* 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, serverProxyEnv, directProxyLogLine } 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; },
|
||
get browserManager() { return browserManager; }, handleCommandInternal, // tab-external-close.test.ts drives real dispatch
|
||
};
|
||
|
||
// ─── 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 tabWarning = await browserManager.ensureCommandTab(command);
|
||
|
||
// 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 session = browserManager.getActiveSession();
|
||
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)) {
|
||
const session = browserManager.getActiveSession();
|
||
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: tabWarning ? `${tabWarning}\n${result}` : 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) ──────────────────────────────
|
||
// The CLI resolves the proxy (--proxy or env, #915). SOCKS5 with auth runs a
|
||
// local 127.0.0.1 bridge to the authenticated upstream (Chromium can't do
|
||
// SOCKS5 auth itself); HTTP/HTTPS or unauthenticated SOCKS5 pass through.
|
||
let proxyBridge: BridgeHandle | null = null;
|
||
const { url: proxyUrl, source: proxySource, bypass: proxyBypass, direct: proxyDirect } = serverProxyEnv(process.env);
|
||
if (proxyUrl) {
|
||
let parsed;
|
||
try {
|
||
parsed = parseProxyConfig({
|
||
proxyUrl,
|
||
envUser: process.env.BROWSE_PROXY_USER,
|
||
envPass: process.env.BROWSE_PROXY_PASS, source: proxySource,
|
||
});
|
||
} 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}`, bypass: proxyBypass });
|
||
} else {
|
||
// HTTP/HTTPS or unauth SOCKS5 — pass through to Chromium directly.
|
||
browserManager.setProxyConfig({
|
||
server: `${parsed.scheme}://${parsed.host}:${parsed.port}`, bypass: proxyBypass,
|
||
...(parsed.userId ? { username: parsed.userId } : {}),
|
||
...(parsed.password ? { password: parsed.password } : {}),
|
||
});
|
||
console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} from ${proxySource} (pass-through to Chromium)`);
|
||
}
|
||
|
||
// Tear down bridge on shutdown.
|
||
process.on('exit', () => {
|
||
if (proxyBridge) {
|
||
proxyBridge.close().catch(() => { /* shutting down anyway */ });
|
||
}
|
||
});
|
||
} else console.log(directProxyLogLine(process.env));
|
||
|
||
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 } : {}),
|
||
...(proxyUrl ? { proxy: redactProxyUrl(proxyUrl), proxySource, proxyBypass } : proxyDirect ? { proxyDirect } : {}), // #915/#3440 refusal detail
|
||
// 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);
|
||
});
|
||
}
|