mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-03 01:46:55 +02:00
* refactor(resolvers): split review.ts into MECE resolver modules (pure move) Move every function from scripts/resolvers/review.ts, unchanged, into: - review-dashboard.ts: review dashboard, plan-file review report - plan-gates.ts: approval check, exit-plan-mode gate, plan-file discovery, plan-completion audit/gate (ship + review), plan verification exec - spec-review.ts: both spec review loops, benefits-from, anti-shortcut clause - outside-voice-steps.ts: Codex second opinion, adversarial step, Codex plan review, Codex doc review, disabled-outside record - review-scope.ts: scope drift, cross-review dedup, shared-code reuse review.ts is deleted; index.ts imports the new modules. gen-skill-docs output is byte-identical for every host (--host all). Test imports and source-path references are re-pointed; the two source-text report/gate tests in gen-skill-docs.test.ts become behavioral renders across every consuming skill and host. All 46 touchfile entries that named review.ts now name all five modules, guarded by a recorded selection golden. * test(browse): black-box auth matrix for every server route and both surfaces Drives buildFetchHandler fetchLocal/fetchTunnel with no token, wrong token, root token, scoped token and the SSE cookie for all 33 routes, plus unmatched paths and wrong methods. Denials assert today's exact status, body and content type; allowed credentials assert the handler was reached. Written against the unchanged if-chain server so the W3 route-table refactor must keep it green. * refactor(shard-engine): move scripts/test-strict-output.ts to scripts/lib/shard-engine.ts The shared shard engine grows from the existing strict-output module (runShardChild, killProcessGroup, signal forwarding, strict classifier). scripts/test-strict-output.ts stays as a re-export so existing importers, mock.module paths and the strict-output/run-shard-child tests are unchanged. The engine inherits the global touchfile entry; the free runner's CLI-routing fixture copies the new module. * refactor(resolvers): decompose the three >150-line review resolvers (output-neutral) Split generateAdversarialStep, generateCodexPlanReview and generatePlanCompletionAuditInner into per-section helpers whose template literals are copied verbatim, so every function in the new modules is at or under 150 lines. gen-skill-docs output is byte-identical for every host (--host all, compared against96764e80with a fixed --link-root). * refactor(resolvers): one outside-voice failure policy (deliberate prose unification) outsideVoiceFailurePolicy(ctx, opts) in outside-voice.ts now renders the auth / timeout / empty-response bullets for all four call sites that hand-typed them (Codex second opinion, adversarial step, Codex plan review, design outside voices). Options are explicit per site (timeoutMinutes, onTimeout, stderrOnEmpty, fallback, escape) with no defaults. Deliberate generated-prose changes (every host): - office-hours: 'Fall back to <native> subagent.' becomes 'Fall back to the <native> subagent below.' - plan-devex-review: the plain 'Auth failure (stderr contains ...)' bullets become the canonical bold bullets; auth also triggers on 'API key'; 'auth failed' becomes 'authentication failed'. - review/ship adversarial: 'exceeded 9 minutes and was terminated' becomes 'timed out after 9 minutes and was terminated'; the timeout is still MISSING COVERAGE. - design outside voices: unchanged. Adds ratchet (d) (test/outside-voice-failure-policy.test.ts) with a reasoned allowlist for /codex's own CLI errors, the MISSING COVERAGE retention test, refreshed codex/factory ship goldens, and outside-voice.ts in every touchfile entry of review.ts and design.ts (selection golden extended). * test(pty): fake PTY session driver with an injectable clock through the runner launch seam The three plan-skill runners take an optional PtyDriver (launch, now, monotonic, sleep); omitted, they use the real launcher and clocks exactly as before. test/helpers/pty/fake-session.ts feeds scripted frames through that seam, and claude-pty-runner.runners.unit.test.ts runs observation, counting and floor for success, deadline timeout, permission prompt and plan-ready outcomes with no CLI or real timers. These cases must stay green unchanged through the W4 split and the runPtySession extraction. Touchfiles: every entry that lists claude-pty-runner.ts or pty-screen.ts now also lists test/helpers/pty/**. * refactor(shard-engine): run both lanes on the shared engine; lane policy injected Engine (scripts/lib/shard-engine.ts) gains the W2 primitives: per-shard tmp/Chromium sandbox + async cleanup backstop, log-path allocation and full-stream log capture, one duration-seed reader/writer with a lane predicate, LanePolicy (seed predicate + zero-execution verdict), strictShardStatus, and the shared CLI flag loop. runShardChild takes an optional companion (signal/settle) and waits a bounded 250ms to reap a wall-killed child. Free lane stops spawning shards itself: runFreeShard uses runShardChild with trackShardBrowser as the companion (win32 path unchanged: no process group, no negative-pid kill). Its sync state-dir removal stays lane policy. Paid lane uses the sandbox, log, seed, verdict and flag primitives; the hollow-shard guard applies PAID_LANE_POLICY. Lane outcomes are unchanged (free keeps >= 0 seeds and file-count zero-exec rule; paid keeps > 0 seeds, warning under selection and passed-empty under EVALS_ALL). paid-free-boundary's closure assertion now names the engine module, where the strict classifier lives. * test(shard-engine): engine unit tests, fixture-corpus equivalence, per-lane CLI parity - test/shard-engine.test.ts: failing/unhandled/module-load output fails both lanes, per-lane zero-execution and seed rules, whole-group kill on a wall timeout (both lanes), mocked-win32 path with no negative-pid kill, companion settle order, log capture, sandbox isolation, flag loop. - test/shard-engine-equivalence.test.ts + test/fixtures/shard-equivalence: seven outcome fixtures plus one real shard, run through both lanes and compared with classifications recorded from the base runners (96764e80). - test/shard-cli-parity.test.ts + test/fixtures/shard-cli-parity: flag set, defaults, validation errors and the Unknown argument error per lane match the base runners. * refactor(shard-engine): decompose runFreeShard and runPaidShard to <= 150 lines Output-neutral extraction under the fixture-corpus equivalence and runner tests: captureFreeStream, explainFreeVerdict and logFreeRecovery (free); paidShardCommand, settleShardSpool, settleBootstrapRetention and printLogTail (paid). The bootstrap scope-creation block that bootstrap-retention.test.ts evaluates stays verbatim. * refactor(pty): split claude-pty-runner.ts into test/helpers/pty/* behind a barrel Pure move: every line of the former 5,047-line runner lands verbatim in one module (four private helpers gain `export` for cross-module use): binary, screen (absorbs test/helpers/pty-screen.ts, which now re-exports it), launch, session (PtyDriver), judge, classify, auq, plan-native, boundaries, runners/{observation,counting,floor}. claude-pty-runner.ts re-exports the original public surface by name; pty/ modules import siblings directly. Tests that read the runner's source text: - rewritten as behavioral: the unit test's model-pin tripwire (fake CLI argv: fallback chain, --model before extraArgs, hermetic --strict-mcp-config), pty-skill-seeding-wiring (runners through the fake driver; launcher through a fake CLI reporting CLAUDE_CONFIG_DIR). The "three wrappers forward model" grep is replaced by the runners' fake-driver launch assertions. - pty-screen-session / pty-screen-supervision: stop copying runner source; they mock.module the real pty/screen.ts (and the fixture cleanup) instead. - re-pointed to the owning module (they execute a sliced runner body with injected boundaries; no seam exists for those boundaries yet): eng-seeded-completion-ai, plan-floor-permission, plan-create-prepublication, plan-count-completion; hermetic-wiring's source guard now reads pty/launch.ts and scans every pty/ module for raw process.env spreads. - plan-count-timeout and pty-output-wake mock the viewport at pty/screen.ts. * test(ratchet-c): enforcing module/function size ratchet and moved-code touchfile coverage Ratchet (c) ships enforcing: test/helpers/module-size.ts counts file and top-level function lengths by brace matching over masked source (strings, comments, regex literals and template text masked; ${} expressions kept), covering function declarations, arrow functions assigned to consts and route-table handler properties, with no parser dependency. Its self-test uses template literals and code-fence braces copied from scripts/resolvers/review.ts and design.ts. test/fixtures/module-size-ratchet.json binds scripts/lib/shard-engine.ts (<= 800 lines, <= 150 per function) and records the residual runner sizes (free 2352, paid 1921) as non-growth caps; allowlist entries are keyed on file plus matched text and need a reason. Failure output lists file:line, the rule, Fix: and the allowlist path. touchfiles.test.ts gains the moved-code superset check over test/fixtures/touchfile-move-goldens/ (W2 golden recorded at96764e80: test-strict-output.ts and test-paid-shards.ts global, test-free-shards.ts none). * refactor(browse): declared route table replaces the buildFetchHandler if-chain The ~1,300-line if-chain in buildFetchHandler becomes a route table: each entry declares method, path, auth kind and surfaces, and one auth gate in browse/src/routes/table.ts returns the per-kind denial (root-bearer, scoped, root-or-sse-cookie: 401 Unauthorized; root-token: 403 Root token required; extension-origin: 403 Forbidden). Unmatched requests take the declared fallthrough (root-bearer check, then plain-text 404). Handlers move to browse/src/routes/{core,pairing,pty,tokens,tunnel,activity,commands,files, inspector}.ts and receive a RouteContext with auth checks as functions instead of closing over factory locals. Dispatch order is unchanged: tunnel filter, beforeRoute overlay, gate, handler. TUNNEL_PATHS stays a literal in server.ts. Behavior-preserving: the black-box auth matrix from the previous commit passes unchanged. /memory and /inspector/events are declared root-bearer because the blanket check always ran before their SSE-cookie branch. Source-text route tests are rewritten as behavioral tests through buildFetchHandler or a route's real handler with a stub RouteContext (browse/test/route-test-harness.ts). Checks with no runtime seam are re-pointed to the route modules: Surface type, /inspector/events SSE helper, sanitizeReplacer imports, /pty-inject-scan sidecar-client import, and the ngrok config lookup and startTunnel wiring that stay in server.ts. * test(browse): stubbed-handler auth matrix and route inventory for the route table Every ROUTES entry runs through the real dispatcher and gate with stub handlers on each declared surface and six credentials; denials assert the exact status and body each auth kind returned at96764e8, admitted credentials assert the handler ran (with the gate's TokenInfo for scoped routes). Also pins the reviewed route inventory (method, path, auth kind, surfaces), that every entry declares auth and surfaces, that the table's tunnel paths equal the TUNNEL_PATHS literal with GET /connect admitted, the unmatched fallthrough, and that the root token is rejected on every tunnel route through buildFetchHandler. * test(browse): ratchet (b) keeps route dispatch inside the route table Scans browse/src/server.ts and browse/src/routes/*.ts for pathname comparisons; only the table matcher and the tunnel-surface filter are allowed, listed with reasons in browse/test/fixtures/route-dispatch-allowlist.json (keyed on file plus line text). Also checks every entry declares auth and surfaces and that gstack registers no beforeRoute overlay itself. Self-tests plant a violation and assert the file:line, Fix: and allowlist path in the message, that a shifted line stays allowlisted, and that a reasonless entry is rejected. * test: touchfile superset check for modules moved out of browse/src/server.ts Records the paid evals selected by touching browse/src/server.ts at96764e80(17 E2E, 1 LLM judge) and asserts every browse/src/routes/*.ts module selects a superset. The test reads every golden in test/fixtures/moved-module-selection/ so other moved-code goldens can sit beside it. * test(shard-engine): give non-timeout corpus fixtures CI headroom; keep the POSIX golden off the Windows lane Only the wall-timeout fixture keeps a 3s wall; the rest get 60s so a loaded host cannot turn a pass into a timeout. Base and branch runners still agree on every classification under the new walls. The Windows exclusion entry moves the free runner's ratchet (c) residual cap to 2356 lines. * refactor(pty): one runPtySession loop drives observation, counting and floor test/helpers/pty/session.ts owns launch -> start -> (poll -> tick)* -> timeout and the failure contract the three runners each hand-rolled: the run's own error wins over capture and close errors, close always runs, owned fixture cleanup runs last (also when launch fails). Each runner now supplies a PtySessionPlan: its boot/command step, poll cadence (2s observation/floor sleep; counting's output wake + 250ms coalesce), tick policy (permission handling, native identity, terminal rules stay per runner because they differ) and capture hooks. The runner bodies are decomposed into top-level steps so no function exceeds 150 lines; behavior is unchanged and the fake-driver cases from the first W4 commit pass unmodified. The counting capture step and the native completion-summary predicate are now named functions (countingCapture, isNativeCompletionSummary), so plan-create-prepublication and plan-count-completion call them directly instead of executing sliced source. The two harnesses that still execute a sliced runner body with injected boundaries (eng-seeded-completion-ai, plan-floor-permission) pass the PtyDriver seam instead of overriding Date/Bun.sleep. * test(ratchet-c): register route modules, review resolver modules and server.ts residual cap * refactor(pty): decompose launchClaudePty and engNumberedFindingAUQ under 150 lines launchClaudePty (349 lines) becomes launch preparation (args, hermetic child env, owned state roots), recorder creation, spawn, the trust-dialog watcher, close, and the session handle over one PtyProcess state object. The failure order is unchanged: abort the viewport, dispose any recorders created so far, dispose the viewport, rethrow. The --model / --strict-mcp-config ordering and seedSkills wiring stay pinned by the behavioral fake-CLI tests. engNumberedFindingAUQ (345 lines) keeps its guards and dispatch; each self-contained issue family (declared cache, library retry hooks, cache owner, injected singleton, shared writers, injected export) moves verbatim into its own function. Every pty/ module is now <= 800 lines and every top-level function <= 150 lines. * test(pty): split claude-pty-runner.unit.test.ts along the pty/ module seams The 188 unit tests move verbatim into claude-pty-runner.{screen,classify, auq,launch,plan-native,boundaries}.unit.test.ts (test names unchanged; each file imports only what it uses from the barrel). The five files that no longer read a SKILL.md template join the test-of-test ratchet baseline with a reason. * test(touchfiles): moved PTY modules keep their paid-eval selection test/fixtures/touchfile-selection/w4-pty.json records, at96764e8, the paid evals selected by touching test/helpers/claude-pty-runner.ts (20) and test/helpers/pty-screen.ts (20). touchfiles.test.ts now asserts every .ts file under test/helpers/pty/ (and pty/screen.ts for both sources) selects a superset, reading every golden in that directory so later moves can add one; a planted-violation case pins the report and its Fix line. * fix(browse): unexchanged pair setup keys no longer authenticate bearer requests validateToken accepted a gsk_setup_ key as a bearer on /command, /batch and /file (found while building the W3 auth matrix). A setup key now only authenticates the /connect exchange. * W1: one state-root owner (lib/state-root.ts + bin/gstack-state-root.sh), gstack-paths --explain and fail-stop, parity tests * W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin. * W1: privacy keys and trust-policy deny tiers merge across state roots; gstack-config reporting; test hermeticity readConfigKey / gstack_read_config_key return the most restrictive telemetry, memorable_recall, codex_reviews and update_check across the resolved root and ~/.gstack; other keys read the resolved root only. gstack-config set reports an overriding root with the exact override command, list shows the winning root and a root-variable disagreement line. gstack-gbrain-repo-policy get merges deny/read-only tiers. gstack-egress reads through readConfigKey. test-setup.ts strips inherited GSTACK_STATE_ROOT/GSTACK_STATE_DIR and redirects the legacy root. * W1: shared hook logging helper (hosts/claude/hooks/hook-log.ts) One hook-errors.log writer: root from resolveStateRoot, 0600 on every append, opt-in rate limit used only by memorable-user-prompt. The five hooks route through it. * W1: docs/state-root.md and README troubleshooting pointer Precedence table, a real --explain example, the move-your-state recipe, merged privacy keys, the uninstall rule, the resolver-failure fix, and the plugin-mode note (evidence gate: no official plugin distribution). * W1b: template and resolver prose resolve state through guarded gstack-paths; ratchet (a) Every gstack-paths eval in templates and resolvers carries the fail-stop guard; executable ~/.gstack paths in bash blocks (context recovery preamble, eureka log, analytics, project artifacts, upgrade snooze, setup-gbrain lock, retro snapshots, ship consent marker) use $GSTACK_STATE_ROOT, and the writer prose that pairs with them points at the printed PROJECT_DIR / RETRO_FILE. ship drops export GSTACK_STATE_ROOT. SKILL.md regenerated (claude + codex), ship goldens re-pinned, parity and context-budget caps raised to the measured sizes with notes. test/state-root-ratchet.test.ts enforces the rule with a reasoned allowlist; W1 touchfile entries plus a superset golden. * refactor: apply W1 state-root edits in W2/W3/W5-owned files; one moved-code touchfile golden for all workstreams * test: fold the moved-code touchfile golden into touchfiles.test.ts; fix integration fixture closure and caps * v1.91.11.0: CHANGELOG, TODOS, docs and conventions for the refactor wave * test: re-measure plan-ceo/design-consultation caps and ship goldens after the guarded plan-discovery and spec-review blocks; add the state-root twin to the workflow-boundaries fixture * fix(windows): migrations resolve their directory with either path separator; state-root parity compares under the HOME Git Bash actually sees * fix(review,ship): state plan-check timing after smoke expiry and test_stub Skip semantics (review workflow judge clarity) * test(qa-eval): webhook fix eval asks for the fix loop's post-repair probes; eight-scenario coverage stays in the report-only case and the harness recheck * test(qa-eval): re-pin the webhook prompt contract to the fix-loop stage; R29 coverage omissions stay bound by the report-only case * fix(review,ship): plan checks publish a checkpoint before each probe; only the smoke expiry stop is skipped * fix(qa): carry #2999's checkpoint receipt link, report-template line and full-revision placeholder (identical hunks) * test(qa-callers): disable git auto maintenance in the caller fixture Git 2.47+ runs auto maintenance detached after commit; on the CI runner's git 2.55 it rewrote .git/objects fan-out directories while the write observer was running, which surfaced as unauthorized mutations. Same gc.auto=0 / maintenance.auto=false guard the shared-libs fixture already uses. * test(plan-mode-no-op): require prose evidence for the prose-fallback members so a spinner-frame judge verdict cannot end the run as asked * test(ship-docsync): carry #2999's seeded-attempt docsync harness (identical files) The doc-sync fault cases replayed attempt 1 before reaching their gate and ran out of their 285s budget. The fixture now seeds attempt 1 and the parent starts at the gate under test. Taken byte-identical from origin/capy/audit-fix-wave (fb526898,e6ac813d,6ce10ff7,d0c53577,77cce3be). Local: stale-before, recovery and late-result 6/6 PASS (97-164s); the whole file 12/12 PASS.
2225 lines
102 KiB
TypeScript
2225 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 { sanitizeBody, stripLoneSurrogates } from './sanitize';
|
||
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
|
||
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config';
|
||
import { writeReceipt } from '../../lib/egress-receipt';
|
||
import { redactProxyUrl } from './proxy-redact';
|
||
import { type XvfbHandle } from './xvfb';
|
||
import { logTunnelDenial } from './tunnel-denial-log';
|
||
import { validateSseSessionToken, extractSseCookie } from './sse-session-cookie';
|
||
import { ROUTES } from './routes';
|
||
import { dispatchRoute, type RouteContext, type Surface } from './routes/table';
|
||
import { TUNNEL_COMMANDS, canDispatchOverTunnel } from './routes/commands';
|
||
import { getInspectorSubscriberCount, clearInspectorSubscribers } from './routes/inspector';
|
||
import * as fs from 'fs';
|
||
import * as net from 'net';
|
||
import * as path from 'path';
|
||
import * as crypto from 'crypto';
|
||
|
||
const SERVER_INSTANCE_ID = crypto.randomUUID();
|
||
|
||
function removeOwnedDaemonStateQuiet(): void {
|
||
try {
|
||
const release = acquireAgentStateLock(path.dirname(config.stateFile), 0);
|
||
try {
|
||
const state = JSON.parse(fs.readFileSync(config.stateFile, 'utf8'));
|
||
if (state.pid === process.pid && state.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile);
|
||
} finally { release(); }
|
||
} catch {}
|
||
}
|
||
|
||
// ─── Unicode Sanitization ───────────────────────────────────────
|
||
// Unpaired UTF-16 surrogate halves (\uD800–\uDFFF) in page DOM text, OCR
|
||
// output, and other CDP-sourced strings are rejected by JSON consumers
|
||
// downstream (Anthropic API in particular: "no low surrogate in string").
|
||
// The sanitizers live in sanitize.ts (single source of truth, shared with
|
||
// sse-helpers.ts and the read/snapshot pipeline): `stripLoneSurrogates`
|
||
// replaces lone halves with U+FFFD (valid pairs like emoji survive), and
|
||
// `sanitizeReplacer` runs it on every string value inside JSON.stringify.
|
||
//
|
||
// INVARIANT: every server egress path that ships page-content strings MUST
|
||
// route through the sanitizer. handleCommandInternal wraps the final
|
||
// cr.result string (text/plain bodies carry lone surrogates verbatim;
|
||
// JSON.stringify already escapes them). The SSE producers stringify with
|
||
// `sanitizeReplacer` so payload string fields get cleaned BEFORE escaping.
|
||
// Plain post-stringify regex is a no-op there because JSON.stringify
|
||
// converts \uD800 → "\\ud800" — the regex can't see the surrogate after
|
||
// that point.
|
||
|
||
// ─── Config ─────────────────────────────────────────────────────
|
||
const config = resolveConfig();
|
||
ensureStateDir(config);
|
||
initAuditLog(config.auditLog);
|
||
|
||
// ─── Auth ───────────────────────────────────────────────────────
|
||
// activeShutdown points to the factory-scoped shutdown function once
|
||
// buildFetchHandler has been called. Module-level timers (idle check, parent
|
||
// watchdog) and signal handlers route through activeShutdown so they close
|
||
// the cfg-provided browserManager rather than a stale module-level reference.
|
||
// Null before the first buildFetchHandler call, which is correct: nothing to
|
||
// shut down yet.
|
||
let activeShutdown: ((code?: number) => Promise<void>) | null = null;
|
||
|
||
// AUTH_TOKEN is injectable via process.env.AUTH_TOKEN so embedders
|
||
// (gbrowser's gbd daemon spawn) can pre-allocate the token and hand it to
|
||
// the Bun child via env.
|
||
//
|
||
// Validation: require >= 16 chars after stripping ALL unicode whitespace
|
||
// (not just ASCII — .trim() misses U+200B / U+FEFF / U+00A0 / etc., which
|
||
// would otherwise let a misconfigured embedder ship a one-character BOM as
|
||
// the bearer secret). Reject tokens that are too short or contain only
|
||
// whitespace; fall back to randomUUID so the security boundary is never
|
||
// silently weakened by misconfiguration.
|
||
function sanitizeAuthToken(raw: string | undefined): string | null {
|
||
if (!raw) return null;
|
||
const stripped = raw.replace(/[\s -]/g, '');
|
||
if (stripped.length < 16) return null;
|
||
return stripped;
|
||
}
|
||
// AUTH_TOKEN const + module-level initRegistry call deleted in v1.35.0.0.
|
||
// buildFetchHandler now owns auth state end-to-end: cfg.authToken is the
|
||
// single source of truth, factory body calls initRegistry(cfg.authToken),
|
||
// and factory-scoped validateAuth closes over the same value. start() reads
|
||
// env once via resolveConfigFromEnv() and threads the result through.
|
||
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
||
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
||
|
||
/**
|
||
* Port the local listener bound to. Set once the daemon picks a port.
|
||
* Used by `$B skill run` to point spawned skill scripts at the daemon over
|
||
* loopback. Module-level so handleCommandInternal can read it without threading
|
||
* the port through every dispatch.
|
||
*/
|
||
let LOCAL_LISTEN_PORT: number = 0;
|
||
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
|
||
|
||
// ─── Tunnel State ───────────────────────────────────────────────
|
||
//
|
||
// Dual-listener architecture: the daemon binds TWO HTTP listeners when a
|
||
// tunnel is active. The local listener serves bootstrap + CLI + sidebar
|
||
// (never exposed to ngrok). The tunnel listener serves only the pairing
|
||
// ceremony and scoped-token command endpoints (the ONLY port ngrok forwards).
|
||
//
|
||
// Security property comes from physical port separation: a tunnel caller
|
||
// cannot reach bootstrap endpoints because they live on a different TCP
|
||
// socket, not because of any per-request check.
|
||
let tunnelActive = false;
|
||
let tunnelUrl: string | null = null;
|
||
let tunnelListener: any = null; // ngrok listener handle
|
||
let tunnelServer: ReturnType<typeof Bun.serve> | null = null; // tunnel HTTP listener
|
||
|
||
export type { Surface };
|
||
|
||
/**
|
||
* Factory contract for embedders (gbrowser phoenix overlay).
|
||
*
|
||
* Today the CLI calls `start()` which reads env vars and binds Bun.serve
|
||
* itself. Embedders building on this server as a submodule (gbrowser's
|
||
* fd-passing gbd architecture) need to inject auth + ports + a
|
||
* BrowserManager they pre-launched, and own the listener themselves.
|
||
*
|
||
* Status: v1 surfaces this type as documentation. AUTH_TOKEN env-injection
|
||
* is already live (see ~L70). `start()` is exported and the kickoff /
|
||
* signal-handler registration is gated on `import.meta.main`, so phoenix
|
||
* can `import { start } from '.../server'` without auto-starting. Full
|
||
* `buildFetchHandler` extraction lands in a follow-up; see plan
|
||
* `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md`
|
||
* Part 1.
|
||
*/
|
||
export interface ServerConfig {
|
||
/** Bearer token clients must present. Today injected via AUTH_TOKEN env. */
|
||
authToken: string;
|
||
/** Local listener port. Used in /welcome URL + state-file. */
|
||
browsePort: number;
|
||
/** Result of resolveConfig() — stateDir, auditLog, stateFile. */
|
||
config: ReturnType<typeof resolveConfig>;
|
||
/** Pre-launched BrowserManager. Caller owns lifecycle. */
|
||
browserManager: BrowserManager;
|
||
// NOTE: per-factory idleTimeoutMs and chromiumProfile were deleted — they
|
||
// were documented but never read (the idle timer, activity state, and
|
||
// shutdown target are module-global, so per-factory wiring would lie for
|
||
// any embedder running >1 handler). Real support belongs to the deferred
|
||
// server.ts singleton refactor. Until then: BROWSE_IDLE_TIMEOUT
|
||
// and CHROMIUM_PROFILE env are the honest knobs.
|
||
/** Caller-owned. shutdown() does NOT call xvfb.stop(); caller is responsible. */
|
||
xvfb?: XvfbHandle | null;
|
||
/** Caller-owned. shutdown() does NOT call proxyBridge.close(); caller is responsible. */
|
||
proxyBridge?: BridgeHandle | null;
|
||
startTime: number;
|
||
/**
|
||
* Overlay hook. Runs AFTER gstack resolves auth and BEFORE route dispatch.
|
||
* Invalid tokens are auto-rejected at the gstack layer (401 returned
|
||
* before hook fires), so the hook only ever sees valid TokenInfo or null
|
||
* (no token presented). Returning a Response short-circuits gstack
|
||
* dispatch; returning null falls through.
|
||
*/
|
||
beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise<Response | null>;
|
||
/**
|
||
* Whether gstack owns the lifecycle of the terminal-agent process and its
|
||
* discovery files (`<stateDir>/terminal-port`, `<stateDir>/terminal-internal-token`,
|
||
* `<stateDir>/terminal-agent-pid`).
|
||
*
|
||
* When true (default), shutdown() runs four side effects:
|
||
* 1. Identity-based kill via `killAgentByRecord(readAgentRecord(stateDir))`
|
||
* (v1.44+). Only signals the PID recorded by THIS daemon's agent.
|
||
* Replaced the historical `pkill -f terminal-agent\.ts` regex that
|
||
* matched sibling gstack sessions on the same host — see
|
||
* terminal-agent-control.ts for rationale.
|
||
* 2. `safeUnlinkQuiet(<stateDir>/terminal-port)`
|
||
* 3. `safeUnlinkQuiet(<stateDir>/terminal-internal-token)`
|
||
* 4. `safeUnlinkQuiet(<stateDir>/terminal-agent-pid)` (the v1.44 record)
|
||
*
|
||
* This is correct for gstack's CLI path, which spawns `terminal-agent.ts` as
|
||
* the producer of those files (see cli.ts:1037-1063).
|
||
*
|
||
* Embedders (gbrowser phoenix overlay, future hosts) that run their own PTY
|
||
* server and write those files themselves should pass `false`. When `false`,
|
||
* the embedder owns BOTH the agent process AND all three discovery files.
|
||
* Note that terminal-agent.ts's own SIGTERM cleanup removes `terminal-port`
|
||
* and `terminal-agent-pid` (the agent writes both at boot), so embedders
|
||
* that pre-launch their own agent must ensure their cleanup matches.
|
||
*
|
||
* Polarity note: this differs from `xvfb?` and `proxyBridge?`, which gate by
|
||
* the *presence* of a caller-owned handle (presence ⇒ don't close). This
|
||
* field gates by an explicit boolean because there is no handle object —
|
||
* the terminal-agent is started elsewhere (cli.ts), and shutdown's only
|
||
* reference is the PID record + the file paths.
|
||
*/
|
||
ownsTerminalAgent?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Return shape of buildFetchHandler() — fetch handlers + lifecycle helpers
|
||
* embedders need to drive their own Bun.serve binding. See ServerConfig.
|
||
*/
|
||
export interface ServerHandle {
|
||
fetchLocal: (req: Request, server: any) => Promise<Response>;
|
||
fetchTunnel: (req: Request, server: any) => Promise<Response>;
|
||
/**
|
||
* Drains buffers, kills terminal-agent, closes browser, clears intervals,
|
||
* removes state files. Does NOT stop bound Bun.Server listeners — call
|
||
* stopListeners() for that. CLI relies on process.exit() to drop sockets.
|
||
*/
|
||
shutdown: (exitCode?: number) => Promise<void>;
|
||
/**
|
||
* Graceful listener stop for embedders. Calls server.stop(true) on each
|
||
* passed Bun.Server. CLI doesn't need this (process.exit handles it).
|
||
*/
|
||
stopListeners: (local: any, tunnel?: any) => Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* Build a ServerConfig-shaped object from process.env. Used by gstack's
|
||
* own CLI when running `bun run dev` or the compiled binary directly.
|
||
* Embedders construct their own ServerConfig explicitly.
|
||
*
|
||
* Reads env, calls resolveConfig(). Does NOT bind a listener or call
|
||
* initAuditLog/initRegistry — those happen inside the buildFetchHandler
|
||
* lifecycle.
|
||
*/
|
||
export function resolveConfigFromEnv(): Omit<ServerConfig, 'browserManager' | 'startTime'> & {
|
||
config: ReturnType<typeof resolveConfig>;
|
||
} {
|
||
return {
|
||
// Same sanitizer as the module-level AUTH_TOKEN: strips ALL unicode
|
||
// whitespace and rejects tokens shorter than 16 chars so a misconfigured
|
||
// embedder can't ship a BOM/zero-width as the bearer secret.
|
||
authToken: sanitizeAuthToken(process.env.AUTH_TOKEN) || crypto.randomUUID(),
|
||
browsePort: parseInt(process.env.BROWSE_PORT || '0', 10),
|
||
config: resolveConfig(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Paths reachable over the tunnel surface. Everything else returns 404.
|
||
*
|
||
* `/connect` is the only unauthenticated tunnel endpoint — POST for setup-key
|
||
* exchange, GET for an `{alive: true}` probe used by /pair and /tunnel/start
|
||
* to detect dead ngrok tunnels. Other paths in this set require a scoped
|
||
* token via Authorization: Bearer.
|
||
*
|
||
* Updating this set is a deliberate security decision. Every addition widens
|
||
* the tunnel attack surface. It must equal the paths of the route-table
|
||
* entries that declare the 'tunnel' surface (browse/test/server-route-table.test.ts),
|
||
* so widening the tunnel means editing this literal and the entry.
|
||
*/
|
||
const TUNNEL_PATHS = new Set<string>([
|
||
'/connect',
|
||
'/command',
|
||
]);
|
||
|
||
/**
|
||
* The gstack sidebar extension's pinned Chrome extension ID. Derived from
|
||
* the "key" field in extension/manifest.json (first 16 bytes of SHA-256 of
|
||
* the DER public key, hex nibbles mapped 0-9a-f → a-p). Reproduce with:
|
||
* bun browse/scripts/extension-id.ts
|
||
* POST /extension-token releases AUTH_TOKEN only to an Origin of exactly
|
||
* `chrome-extension://<this id>`. If the manifest keypair is ever rotated,
|
||
* this constant must be updated in the same commit.
|
||
*/
|
||
export const GSTACK_EXTENSION_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep';
|
||
|
||
/**
|
||
* The extension-origin auth check (POST /extension-token): Origin is exactly
|
||
* the pinned extension and Host is loopback. Defense-in-depth alongside the
|
||
* 127.0.0.1 bind: a DNS-rebinding page can't present a localhost Host header.
|
||
* Host arrives as '127.0.0.1:34567', so parse out the hostname — never compare
|
||
* the raw header (which carries the port) against a literal.
|
||
*/
|
||
function isPinnedExtensionRequest(req: Request): boolean {
|
||
let hostname: string | null = null;
|
||
try {
|
||
hostname = new URL(`http://${req.headers.get('host') ?? ''}`).hostname;
|
||
} catch (err) {
|
||
if (!(err instanceof TypeError)) throw err; // TypeError = malformed Host
|
||
}
|
||
const originOk = req.headers.get('origin') === `chrome-extension://${GSTACK_EXTENSION_ID}`;
|
||
const hostOk = hostname === '127.0.0.1' || hostname === 'localhost';
|
||
return originOk && hostOk;
|
||
}
|
||
|
||
export { TUNNEL_COMMANDS, canDispatchOverTunnel };
|
||
|
||
/**
|
||
* Read ngrok authtoken from env var, ~/.gstack/ngrok.env, or ngrok's native
|
||
* config files. Returns null if nothing found. Shared between the
|
||
* /tunnel/start handler and the BROWSE_TUNNEL=1 auto-start flow.
|
||
*/
|
||
function resolveNgrokAuthtoken(): string | null {
|
||
let authtoken = process.env.NGROK_AUTHTOKEN;
|
||
if (authtoken) return authtoken;
|
||
|
||
const home = process.env.HOME || '';
|
||
const ngrokEnvPath = path.join(home, '.gstack', 'ngrok.env');
|
||
if (fs.existsSync(ngrokEnvPath)) {
|
||
try {
|
||
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
|
||
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
|
||
if (match) return match[1].trim();
|
||
} catch {}
|
||
}
|
||
|
||
const ngrokConfigs = [
|
||
path.join(home, 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
|
||
path.join(home, '.config', 'ngrok', 'ngrok.yml'),
|
||
path.join(home, '.ngrok2', 'ngrok.yml'),
|
||
];
|
||
for (const conf of ngrokConfigs) {
|
||
try {
|
||
const content = fs.readFileSync(conf, 'utf-8');
|
||
const match = content.match(/authtoken:\s*(.+)/);
|
||
if (match) return match[1].trim();
|
||
} catch {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Tear down the tunnel: close the ngrok listener and stop the tunnel-surface
|
||
* Bun.serve listener. Safe to call with nothing running. Always clears
|
||
* tunnel state regardless of individual close failures.
|
||
*/
|
||
async function closeTunnel(): Promise<void> {
|
||
try { if (tunnelListener) await tunnelListener.close(); } catch {}
|
||
try { if (tunnelServer) tunnelServer.stop(true); } catch {}
|
||
tunnelListener = null;
|
||
tunnelServer = null;
|
||
tunnelUrl = null;
|
||
tunnelActive = false;
|
||
}
|
||
|
||
/**
|
||
* Result of startTunnel(). `stage` tells the caller which half failed so it
|
||
* can keep its distinct error surface: 'bind' = the tunnel-surface Bun.serve
|
||
* listener could not bind (nothing to clean up), 'ngrok' = anything after the
|
||
* bind (ngrok forward, egress receipt, state-file write) — startTunnel has
|
||
* already torn down both ngrok and the Bun listener by the time it returns.
|
||
*/
|
||
type StartTunnelResult =
|
||
| { ok: true; url: string }
|
||
| { ok: false; stage: 'bind' | 'ngrok'; error: Error };
|
||
|
||
/**
|
||
* Start the ngrok tunnel using the dual-listener pattern: bind a dedicated
|
||
* tunnel-surface listener on an ephemeral 127.0.0.1 port and point
|
||
* ngrok.forward() at THAT port — the local listener (which serves
|
||
* /extension-token, /cookie-picker, /inspector/*, welcome, etc.) is never
|
||
* exposed to ngrok. Shared by the /tunnel/start route handler (which passes
|
||
* its in-closure makeFetchHandler('tunnel')) and the BROWSE_TUNNEL=1
|
||
* auto-start flow in start() (which passes handle.fetchTunnel from the
|
||
* factory). The BROWSE_TUNNEL_LOCAL_ONLY=1 test path does NOT use this
|
||
* helper — it binds the tunnel surface with no ngrok forwarding at all.
|
||
*
|
||
* Hard fail on listener bind (`stage: 'bind'`) — NEVER fall back to the
|
||
* local port, which would silently defeat the whole security property.
|
||
*
|
||
* On success, sets the module tunnel state (tunnelListener / tunnelUrl /
|
||
* tunnelServer / tunnelActive) and records the tunnel in the state file.
|
||
*/
|
||
async function startTunnel(opts: {
|
||
fetchHandler: (req: Request, server: any) => Promise<Response>;
|
||
authtoken: string;
|
||
consent: string;
|
||
}): Promise<StartTunnelResult> {
|
||
// Bind the tunnel listener on an ephemeral port. HARD FAIL if this
|
||
// errors — never fall back to the local port.
|
||
let boundTunnel: ReturnType<typeof Bun.serve>;
|
||
try {
|
||
boundTunnel = Bun.serve({
|
||
port: 0,
|
||
hostname: '127.0.0.1',
|
||
fetch: opts.fetchHandler,
|
||
});
|
||
} catch (err: any) {
|
||
return { ok: false, stage: 'bind', error: err };
|
||
}
|
||
const tunnelPort = boundTunnel.port;
|
||
|
||
// Point ngrok at the TUNNEL port (not the local port). If this fails,
|
||
// tear the listener back down so we don't leak sockets.
|
||
try {
|
||
const ngrok = await import('@ngrok/ngrok');
|
||
const domain = process.env.NGROK_DOMAIN;
|
||
const forwardOpts: any = { addr: tunnelPort, authtoken: opts.authtoken };
|
||
if (domain) forwardOpts.domain = domain;
|
||
|
||
// Egress receipt BEFORE the tunnel session opens, fail-closed: a
|
||
// writeReceipt failure lands in this catch, which tears the tunnel
|
||
// listener back down and refuses the start. One receipt per session
|
||
// open; browse command behavior over the tunnel is unchanged.
|
||
writeReceipt({
|
||
sink: 'browse-tunnel',
|
||
host: domain || 'connect.ngrok-agent.com',
|
||
payloadClass: 'tunnel-session-open (scoped-token browser-command surface)',
|
||
bytes: 0,
|
||
sha256: null,
|
||
consent: opts.consent,
|
||
});
|
||
|
||
tunnelListener = await ngrok.forward(forwardOpts);
|
||
tunnelUrl = tunnelListener.url();
|
||
tunnelServer = boundTunnel;
|
||
tunnelActive = true;
|
||
console.log(`[browse] Tunnel listener bound on 127.0.0.1:${tunnelPort}, ngrok → ${tunnelUrl}`);
|
||
|
||
// Update state file
|
||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||
try {
|
||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||
if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced');
|
||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||
const tmpState = tmpStatePath();
|
||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||
fs.renameSync(tmpState, config.stateFile);
|
||
} finally { releaseStateLock(); }
|
||
|
||
return { ok: true, url: tunnelUrl! };
|
||
} catch (err: any) {
|
||
// Clean up BOTH ngrok and the Bun listener on failure. If
|
||
// ngrok.forward() succeeded but tunnelListener.url() or the
|
||
// state-file write threw, we'd otherwise leak an active ngrok
|
||
// session on the user's account.
|
||
try { if (tunnelListener) await tunnelListener.close(); } catch {}
|
||
try { boundTunnel.stop(true); } catch {}
|
||
tunnelListener = null;
|
||
return { ok: false, stage: 'ngrok', error: err };
|
||
}
|
||
}
|
||
|
||
// Module-level validateAuth deleted in v1.35.0.0. Factory-scoped equivalent
|
||
// in buildFetchHandler closes over cfg.authToken so every internal auth check
|
||
// sees the same token the routes receive.
|
||
|
||
/**
|
||
* Terminal-agent discovery. The non-compiled bun process at
|
||
* `browse/src/terminal-agent.ts` writes its chosen port to
|
||
* `<stateDir>/terminal-port` and the loopback handshake token to
|
||
* `<stateDir>/terminal-internal-token` once it boots. Read on demand —
|
||
* lazy so we don't break tests that don't spawn the agent.
|
||
*/
|
||
function readTerminalPort(): number | null {
|
||
try {
|
||
const f = path.join(path.dirname(config.stateFile), 'terminal-port');
|
||
const v = parseInt(fs.readFileSync(f, 'utf-8').trim(), 10);
|
||
return Number.isFinite(v) && v > 0 ? v : null;
|
||
} catch { return null; }
|
||
}
|
||
function readTerminalInternalToken(): string | null {
|
||
try {
|
||
const f = path.join(path.dirname(config.stateFile), 'terminal-internal-token');
|
||
const t = fs.readFileSync(f, 'utf-8').trim();
|
||
return t.length > 16 ? t : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Push a freshly-minted PTY cookie token to the terminal-agent so its
|
||
* /ws upgrade can validate the cookie. v1.44+: also pushes the bound
|
||
* sessionId so the agent can route /internal/restart and (Commit 3)
|
||
* re-attach back to the same PtySession. Loopback POST authenticated
|
||
* with the internal token written by the agent at startup. If the agent
|
||
* isn't up yet, the extension just retries /pty-session.
|
||
*/
|
||
async function grantPtyToken(token: string, sessionId?: string): Promise<boolean> {
|
||
const port = readTerminalPort();
|
||
const internal = readTerminalInternalToken();
|
||
if (!port || !internal) return false;
|
||
try {
|
||
const resp = await fetch(`http://127.0.0.1:${port}/internal/grant`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${internal}`,
|
||
},
|
||
body: JSON.stringify(sessionId ? { token, sessionId } : { token }),
|
||
signal: AbortSignal.timeout(2000),
|
||
});
|
||
return resp.ok;
|
||
} catch { return false; }
|
||
}
|
||
|
||
/**
|
||
* Ask the terminal-agent to dispose the PtySession bound to `sessionId`.
|
||
* Scoped to one caller's session — sibling tabs/agents untouched. Used by
|
||
* /pty-restart and /pty-dispose. Returns true on agent ack.
|
||
*/
|
||
async function restartPtySession(sessionId: string): Promise<boolean> {
|
||
const port = readTerminalPort();
|
||
const internal = readTerminalInternalToken();
|
||
if (!port || !internal) return false;
|
||
try {
|
||
const resp = await fetch(`http://127.0.0.1:${port}/internal/restart`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${internal}`,
|
||
},
|
||
body: JSON.stringify({ sessionId }),
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
return resp.ok;
|
||
} catch { return false; }
|
||
}
|
||
|
||
/** Extract bearer token from request. Returns the token string or null. */
|
||
function extractToken(req: Request): string | null {
|
||
const header = req.headers.get('authorization');
|
||
if (!header?.startsWith('Bearer ')) return null;
|
||
return header.slice(7);
|
||
}
|
||
|
||
/** Validate token and return TokenInfo. Returns null if invalid/expired. */
|
||
function getTokenInfo(req: Request): TokenInfo | null {
|
||
const token = extractToken(req);
|
||
if (!token) return null;
|
||
return validateScopedToken(token);
|
||
}
|
||
|
||
/** Check if request is from root token (local use). */
|
||
function isRootRequest(req: Request): boolean {
|
||
const token = extractToken(req);
|
||
return token !== null && isRootToken(token);
|
||
}
|
||
|
||
// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
|
||
function generateHelpText(): string {
|
||
// Group commands by category
|
||
const groups = new Map<string, string[]>();
|
||
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
|
||
const display = meta.usage || cmd;
|
||
const list = groups.get(meta.category) || [];
|
||
list.push(display);
|
||
groups.set(meta.category, list);
|
||
}
|
||
|
||
const categoryOrder = [
|
||
'Navigation', 'Reading', 'Interaction', 'Inspection',
|
||
'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server',
|
||
];
|
||
|
||
const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:'];
|
||
for (const cat of categoryOrder) {
|
||
const cmds = groups.get(cat);
|
||
if (!cmds) continue;
|
||
lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`);
|
||
}
|
||
|
||
// Snapshot flags from source of truth
|
||
lines.push('');
|
||
lines.push('Snapshot flags:');
|
||
const flagPairs: string[] = [];
|
||
for (const flag of SNAPSHOT_FLAGS) {
|
||
const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
|
||
flagPairs.push(`${label} ${flag.long}`);
|
||
}
|
||
// Print two flags per line for compact display
|
||
for (let i = 0; i < flagPairs.length; i += 2) {
|
||
const left = flagPairs[i].padEnd(28);
|
||
const right = flagPairs[i + 1] || '';
|
||
lines.push(` ${left}${right}`);
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
||
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
||
|
||
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
||
|
||
const CONSOLE_LOG_PATH = config.consoleLog;
|
||
const NETWORK_LOG_PATH = config.networkLog;
|
||
const DIALOG_LOG_PATH = config.dialogLog;
|
||
|
||
/**
|
||
* Per-process state-file temp path. The state-file write pattern is
|
||
* `writeFileSync(tmp, ...) → renameSync(tmp, stateFile)` for atomicity,
|
||
* but a shared `${stateFile}.tmp` filename means two concurrent writers
|
||
* (cold-start race when N CLIs hit a fresh repo simultaneously, parallel
|
||
* /tunnel/start handlers, or a combination) collide on the rename: the
|
||
* first writer's renameSync moves the shared temp file out of the way,
|
||
* the second writer's writeFileSync re-creates it, the second rename
|
||
* then races with the first writer's already-renamed state. Worst case
|
||
* the second renameSync throws ENOENT mid-air, killing one of the
|
||
* spawning daemons during startup.
|
||
*
|
||
* Per-process suffix (pid + 4 random bytes) makes each writer's temp
|
||
* path unique. The atomic rename still gives last-writer-wins semantics
|
||
* for the final state.json content; the only behavior change is that
|
||
* concurrent writers no longer kill each other on the rename.
|
||
*/
|
||
function tmpStatePath(stateFile: string = config.stateFile): string {
|
||
return `${stateFile}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
||
}
|
||
|
||
|
||
// ─── Sidebar agent / chat state ripped ──────────────────────────────
|
||
let lastConsoleFlushed = 0;
|
||
let lastNetworkFlushed = 0;
|
||
let lastDialogFlushed = 0;
|
||
let flushInProgress = false;
|
||
|
||
async function flushBuffers() {
|
||
if (flushInProgress) return; // Guard against concurrent flush
|
||
flushInProgress = true;
|
||
|
||
try {
|
||
// Console buffer
|
||
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
||
if (newConsoleCount > 0) {
|
||
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
||
const lines = entries.map(e =>
|
||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||
).join('\n') + '\n';
|
||
appendSecureFile(CONSOLE_LOG_PATH, lines);
|
||
lastConsoleFlushed = consoleBuffer.totalAdded;
|
||
}
|
||
|
||
// Network buffer
|
||
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
||
if (newNetworkCount > 0) {
|
||
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
||
const lines = entries.map(e =>
|
||
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||
).join('\n') + '\n';
|
||
appendSecureFile(NETWORK_LOG_PATH, lines);
|
||
lastNetworkFlushed = networkBuffer.totalAdded;
|
||
}
|
||
|
||
// Dialog buffer
|
||
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
||
if (newDialogCount > 0) {
|
||
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
||
const lines = entries.map(e =>
|
||
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
||
).join('\n') + '\n';
|
||
appendSecureFile(DIALOG_LOG_PATH, lines);
|
||
lastDialogFlushed = dialogBuffer.totalAdded;
|
||
}
|
||
} catch (err: any) {
|
||
console.error('[browse] Buffer flush failed:', err.message);
|
||
} finally {
|
||
flushInProgress = false;
|
||
}
|
||
}
|
||
|
||
// Flush every 1 second
|
||
const flushInterval = setInterval(flushBuffers, 1000);
|
||
|
||
// ─── Idle Timer ────────────────────────────────────────────────
|
||
let lastActivity = Date.now();
|
||
|
||
function resetIdleTimer() {
|
||
lastActivity = Date.now();
|
||
}
|
||
|
||
// Named for behavioral testing via __testInternals__. The factory tests in
|
||
// server-factory.test.ts call this directly so the idle-shutdown path can be
|
||
// exercised without waiting 60s for the interval to fire.
|
||
function idleCheckTick() {
|
||
// Headed mode: the user is looking at the browser. Never auto-die.
|
||
// Only shut down when the user explicitly disconnects or closes the window.
|
||
// Reads via the activeBrowserManager indirection so embedders that pass
|
||
// their own BrowserManager into buildFetchHandler hit the right instance.
|
||
if (activeBrowserManager.getConnectionMode() === 'headed') return;
|
||
// Tunnel mode: remote agents may send commands sporadically. Never auto-die.
|
||
if (tunnelActive) return;
|
||
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
||
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
|
||
activeShutdown?.();
|
||
}
|
||
}
|
||
const idleCheckInterval = setInterval(idleCheckTick, 60_000);
|
||
|
||
// Test-only surface for server-factory.test.ts. Lets the dual-instance
|
||
// idle-timer behavior be exercised deterministically without mutating
|
||
// Date.now (which would interact with the leaked module-level setInterval).
|
||
// Production code must never import this — see `idle timer + onDisconnect
|
||
// dual-instance fix` describe block for usage.
|
||
export const __testInternals__ = {
|
||
serverInstanceId: SERVER_INSTANCE_ID,
|
||
tunnelPaths: TUNNEL_PATHS as ReadonlySet<string>,
|
||
idleCheckTick,
|
||
// Watchdog seams (watchdog.test.ts): drive the 15s poll against an
|
||
// arbitrary (dead) PID, trigger the handoff-promotion suppression exactly
|
||
// as onHeadedPromotion does, and reset the latches between tests.
|
||
parentWatchdogTick,
|
||
suppressHeadedParentShutdown,
|
||
resetParentWatchdogState: () => { headedParentShutdownSuppressed = false; parentGone = false; },
|
||
setTunnelActive: (v: boolean) => { tunnelActive = v; },
|
||
setLastActivity: (t: number) => { lastActivity = t; },
|
||
formatExplicitPortUnavailableError,
|
||
formatRandomPortUnavailableError,
|
||
// Reset the module-level shutdown latch so tests that drive shutdown to
|
||
// completion (process.exit-stubbed) can be followed by tests that also
|
||
// need shutdown to fire. Without this, the second test's shutdown
|
||
// returns early at the `if (isShuttingDown) return;` guard.
|
||
resetShutdownState: () => { isShuttingDown = false; },
|
||
};
|
||
|
||
// ─── Parent-Process Watchdog ────────────────────────────────────────
|
||
// When the spawning CLI process (e.g. a Claude Code session) exits, this
|
||
// server can become an orphan — keeping chrome-headless-shell alive and
|
||
// causing console-window flicker on Windows. Poll the parent PID every 15s
|
||
// and self-terminate if it is gone.
|
||
//
|
||
// Headed mode (BROWSE_HEADED=1 or BROWSE_PARENT_PID=0): The user controls
|
||
// the browser window lifecycle. The CLI exits immediately after connect,
|
||
// so the watchdog would kill the server prematurely. Disabled in both cases
|
||
// as defense-in-depth — the CLI sets PID=0 for headed mode, and the server
|
||
// also checks BROWSE_HEADED in case a future launcher forgets.
|
||
// Cleanup happens via browser disconnect event or $B disconnect.
|
||
const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
|
||
// Outer gate: if the spawner explicitly marks this as headed (env var set at
|
||
// launch time), skip registering the watchdog entirely. Cheaper than entering
|
||
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
|
||
// so this branch is the normal path for /open-gstack-browser.
|
||
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
|
||
// Runtime promotion to headed (`handoff`) must NOT clear this interval — the
|
||
// same tick is the tunnel-orphan reaper, and idle timeout is disabled in
|
||
// tunnel mode, so parent death is the ONLY thing that reaps an
|
||
// internet-exposed daemon after handoff → resume → /pair-agent. Promotion
|
||
// sets this suppress flag instead; the tick re-reads it (and tunnelActive)
|
||
// every pass. See suppressHeadedParentShutdown() below.
|
||
let headedParentShutdownSuppressed = false;
|
||
// Latch for the one-time "parent exited, staying alive" log line.
|
||
let parentGone = false;
|
||
// Named + parameterized (default: the boot-time env PID) so watchdog.test.ts
|
||
// can drive the tick deterministically via __testInternals__, mirroring
|
||
// idleCheckTick above. setInterval invokes it with no args in production.
|
||
function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
|
||
try {
|
||
process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent
|
||
} catch {
|
||
// Parent exited. Resolution order:
|
||
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||
// regardless of mode — tearing down the server mid-import leaves the
|
||
// picker UI with a stale "Failed to fetch" error.
|
||
// 2. Headed (unless suppressed by a runtime promotion) / tunnel mode?
|
||
// Shutdown. The idle timeout doesn't apply in these modes (see
|
||
// idleCheckInterval above — both early-return), so ignoring parent
|
||
// death here would leak orphan daemons after /pair-agent or
|
||
// /open-gstack-browser sessions.
|
||
// 3. Normal (headless) mode, or headed-by-promotion? Stay alive. Claude
|
||
// Code's Bash tool kills the parent shell between invocations, and a
|
||
// promoted daemon's user owns the window lifecycle. The idle timeout
|
||
// (30 min) handles eventual cleanup.
|
||
if (hasActivePicker()) return;
|
||
const headed = activeBrowserManager.getConnectionMode() === 'headed'
|
||
&& !headedParentShutdownSuppressed;
|
||
if (headed || tunnelActive) {
|
||
console.log(`[browse] Parent process ${parentPid} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||
activeShutdown?.();
|
||
} else if (!parentGone) {
|
||
parentGone = true;
|
||
console.log(`[browse] Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`);
|
||
}
|
||
}
|
||
}
|
||
// Poll cadence. Env-overridable as a test seam: watchdog.test.ts shrinks it
|
||
// (250ms) so a free-tier test can observe a real tick deciding on a dead
|
||
// parent instead of sleeping through the 15s production cadence. Production
|
||
// launchers never set this; unparsable or non-positive values fall back to 15s.
|
||
const rawWatchdogIntervalMs = parseInt(process.env.BROWSE_PARENT_WATCHDOG_INTERVAL_MS || '', 10);
|
||
const PARENT_WATCHDOG_INTERVAL_MS =
|
||
Number.isFinite(rawWatchdogIntervalMs) && rawWatchdogIntervalMs > 0
|
||
? rawWatchdogIntervalMs
|
||
: 15_000;
|
||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||
setInterval(parentWatchdogTick, PARENT_WATCHDOG_INTERVAL_MS);
|
||
} else if (IS_HEADED_WATCHDOG) {
|
||
console.log('[browse] Parent-process watchdog disabled (headed mode)');
|
||
} else if (BROWSE_PARENT_PID === 0) {
|
||
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
|
||
}
|
||
|
||
/**
|
||
* Suppress the headed-mode parent-death shutdown after a runtime promotion.
|
||
*
|
||
* The watchdog's contract is "headless daemons outlive their parent, headed ones
|
||
* do not" — reasonable at boot, when mode is fixed by env. `handoff` breaks that
|
||
* assumption: it swaps in a headed context on a RUNNING daemon
|
||
* (browser-manager.ts, connectionMode = 'headed') without a restart, so a daemon
|
||
* that legitimately registered a watchdog is suddenly on the fatal side of the
|
||
* branch. The parent is typically a short-lived shell — Claude Code's Bash tool
|
||
* kills one after every invocation — so the next 15s poll shuts the daemon down,
|
||
* discarding whatever the user was handed off to do, such as a login.
|
||
*
|
||
* Once promoted, the user owns the window lifecycle exactly as if the daemon had
|
||
* been started headed, which is the case the env guards already exempt.
|
||
*
|
||
* A flag, NOT clearInterval: the tick doubles as the tunnel-orphan reaper
|
||
* (its `tunnelActive` branch), and idle timeout is disabled in tunnel mode —
|
||
* clearing the whole interval here left handoff → resume → /pair-agent with
|
||
* an internet-exposed daemon nothing could ever reap. After promotion, parent
|
||
* death no longer kills the daemon for BEING HEADED, but still kills it when
|
||
* a tunnel is active.
|
||
*/
|
||
function suppressHeadedParentShutdown(stateConfig: ServerConfig['config'] = config, manager: BrowserManager = activeBrowserManager): void {
|
||
if (headedParentShutdownSuppressed) return;
|
||
headedParentShutdownSuppressed = true;
|
||
try {
|
||
const release = acquireAgentStateLock(stateConfig.stateDir);
|
||
try {
|
||
const state = JSON.parse(fs.readFileSync(stateConfig.stateFile, 'utf8'));
|
||
if (state.pid === process.pid && state.instanceId === SERVER_INSTANCE_ID) {
|
||
const xvfb = manager.getXvfbHandle();
|
||
state.mode = 'headed';
|
||
delete state.chromiumPid;
|
||
delete state.chromiumStartTime;
|
||
if (xvfb) Object.assign(state, { xvfbPid: xvfb.pid, xvfbStartTime: xvfb.startTime, xvfbDisplay: xvfb.display });
|
||
const tmpFile = tmpStatePath(stateConfig.stateFile);
|
||
try {
|
||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||
fs.renameSync(tmpFile, stateConfig.stateFile);
|
||
} finally { safeUnlinkQuiet(tmpFile); }
|
||
}
|
||
} finally { release(); }
|
||
} catch (err) {
|
||
console.warn('[browse] Could not persist headed promotion:', err instanceof Error ? err.message : String(err));
|
||
}
|
||
console.log('[browse] Parent-death headed shutdown suppressed (promoted to headed at runtime); watchdog stays armed as the tunnel-orphan reaper');
|
||
}
|
||
|
||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||
|
||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||
|
||
/**
|
||
* Whether an invocation should be treated as a WRITE for capability gating
|
||
* (scope, watch-mode block, tab ownership, tunnel). A command is a write if it
|
||
* mutates state (`WRITE_COMMANDS`) OR it carries an `--out` flag — `js`/`eval
|
||
* --out` writes the evaluate result to local disk, so the capability is
|
||
* per-invocation, not per-command-name. This deliberately does NOT change
|
||
* dispatch routing: `js`/`eval` still route to `handleReadCommand`; only the
|
||
* security gates consult this.
|
||
*/
|
||
function isWriteInvocation(command: string, args: string[]): boolean {
|
||
return WRITE_COMMANDS.has(command) || hasOutArg(args);
|
||
}
|
||
|
||
export { getInspectorSubscriberCount };
|
||
|
||
// ─── Server ────────────────────────────────────────────────────
|
||
const browserManager = new BrowserManager();
|
||
// Declared here rather than beside suppressHeadedParentShutdown: that function
|
||
// sits with the watchdog it gates, which is above this line, and binding it up
|
||
// there would touch `browserManager` in its temporal dead zone — aborting
|
||
// module evaluation and leaving every later const uninitialized.
|
||
browserManager.onHeadedPromotion = suppressHeadedParentShutdown;
|
||
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
|
||
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
|
||
// retarget them at a caller-supplied BrowserManager. Symmetric with the
|
||
// existing `let activeShutdown` pattern at module scope (line ~113).
|
||
// Without this, embedders like gbrowser hit the dead module-level instance
|
||
// whose connectionMode never leaves 'launched' — and headed mode never
|
||
// short-circuits idle-shutdown.
|
||
let activeBrowserManager: BrowserManager = browserManager;
|
||
// When the user closes the headed browser window, run full cleanup
|
||
// (kill terminal agent, save session, remove profile locks, delete state file)
|
||
// before exiting. Exit code 0 means user-initiated clean quit (Cmd+Q on
|
||
// macOS) so process supervisors like gbrowser's gbd skip the restart loop;
|
||
// 2 means a real crash that should respawn. The fallback `?? 2` preserves
|
||
// legacy crash semantics for any caller that invokes onDisconnect without
|
||
// an explicit code. This is the safety-net default for the CLI flow before
|
||
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
|
||
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
|
||
let isShuttingDown = false;
|
||
// Session-persist ticker handle. Registered in start() (module scope so the
|
||
// factory's shutdown() can reach it), cleared by shutdown() BEFORE the final
|
||
// snapshot — a tick landing during browser teardown would otherwise overwrite
|
||
// the good final snapshot with a degraded one (zero tabs).
|
||
let sessionPersistInterval: ReturnType<typeof setInterval> | null = null;
|
||
|
||
// Port allocation lives in port-allocator.ts (#2314, decision 8) so the
|
||
// terminal-agent shares the SAME fixed 10000-60000 scan range instead of
|
||
// binding port:0 into the OS ephemeral range. The imports at the top of
|
||
// this file re-expose the pieces __testInternals__ pins.
|
||
|
||
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
||
function findPort(): Promise<number> {
|
||
return findAvailablePort(BROWSE_PORT);
|
||
}
|
||
|
||
/**
|
||
* Translate Playwright errors into actionable messages for AI agents.
|
||
*/
|
||
function wrapError(err: any): string {
|
||
const msg = err.message || String(err);
|
||
// Timeout errors
|
||
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
||
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
||
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
||
}
|
||
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
||
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
||
}
|
||
return `Operation timed out: ${msg.split('\n')[0]}`;
|
||
}
|
||
// Multiple elements matched
|
||
if (msg.includes('resolved to') && msg.includes('elements')) {
|
||
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
||
}
|
||
// Pass through other errors
|
||
return msg;
|
||
}
|
||
|
||
/** Internal command result — used by handleCommand and chain subcommand routing */
|
||
interface CommandResult {
|
||
status: number;
|
||
result: string;
|
||
headers?: Record<string, string>;
|
||
json?: boolean; // true if result is JSON (errors), false for text/plain
|
||
}
|
||
|
||
/**
|
||
* Core command execution logic. Returns a structured result instead of HTTP Response.
|
||
* Used by both the HTTP handler (handleCommand) and chain subcommand routing.
|
||
*
|
||
* Options:
|
||
* skipRateCheck: true when called from chain (chain counts as 1 request)
|
||
* skipActivity: true when called from chain (chain emits 1 event for all subcommands)
|
||
* chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain)
|
||
*/
|
||
async function handleCommandInternalImpl(
|
||
body: { command: string; args?: string[]; tabId?: number },
|
||
tokenInfo?: TokenInfo | null,
|
||
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
|
||
): Promise<CommandResult> {
|
||
const { args = [], tabId } = body;
|
||
const rawCommand = body.command;
|
||
|
||
if (!rawCommand) {
|
||
return { status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), json: true };
|
||
}
|
||
|
||
// ─── Alias canonicalization (before scope, watch, tab-ownership, dispatch) ─
|
||
// Agent-friendly names like 'setcontent' route to canonical 'load-html'. Must
|
||
// happen BEFORE scope check so a read-scoped token calling 'setcontent' is still
|
||
// rejected (load-html lives in SCOPE_WRITE). Audit logging preserves rawCommand
|
||
// so the trail records what the agent actually typed.
|
||
const command = canonicalizeCommand(rawCommand);
|
||
const isAliased = command !== rawCommand;
|
||
|
||
// ─── Recursion guard: reject nested chains ──────────────────
|
||
if (command === 'chain' && (opts?.chainDepth ?? 0) > 0) {
|
||
return { status: 400, result: JSON.stringify({ error: 'Nested chain commands are not allowed' }), json: true };
|
||
}
|
||
|
||
// ─── Scope check (for scoped tokens) ──────────────────────────
|
||
if (tokenInfo && tokenInfo.clientId !== 'root') {
|
||
if (!checkScope(tokenInfo, command)) {
|
||
return {
|
||
status: 403, json: true,
|
||
result: JSON.stringify({
|
||
error: `Command "${command}" not allowed by your token scope`,
|
||
hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Ask the user to re-pair without --restrict for full page access, or with --control for browser control commands.`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// `--out` writes the evaluate result to local disk, which is a WRITE
|
||
// capability distinct from the JS-exec (admin) capability js/eval need.
|
||
// Require write scope so an admin-but-not-write token can't write files.
|
||
if (hasOutArg(args) && !tokenInfo.scopes.includes('write')) {
|
||
return {
|
||
status: 403, json: true,
|
||
result: JSON.stringify({
|
||
error: `"--out" writes to disk and requires the "write" scope`,
|
||
hint: `Your scopes: ${tokenInfo.scopes.join(', ')}. Re-pair with write access to use --out.`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// Domain check for navigation commands
|
||
if ((command === 'goto' || command === 'newtab') && args[0]) {
|
||
if (!checkDomain(tokenInfo, args[0])) {
|
||
return {
|
||
status: 403, json: true,
|
||
result: JSON.stringify({
|
||
error: `Domain not allowed by your token scope`,
|
||
hint: `Allowed domains: ${tokenInfo.domains?.join(', ') || 'none configured'}`,
|
||
}),
|
||
};
|
||
}
|
||
}
|
||
|
||
// Rate check (skipped for chain subcommands — chain counts as 1 request)
|
||
if (!opts?.skipRateCheck) {
|
||
const rateResult = checkRate(tokenInfo);
|
||
if (!rateResult.allowed) {
|
||
return {
|
||
status: 429, json: true,
|
||
result: JSON.stringify({
|
||
error: 'Rate limit exceeded',
|
||
hint: `Max ${tokenInfo.rateLimit} requests/second. Retry after ${rateResult.retryAfterMs}ms.`,
|
||
}),
|
||
headers: { 'Retry-After': String(Math.ceil((rateResult.retryAfterMs || 1000) / 1000)) },
|
||
};
|
||
}
|
||
}
|
||
|
||
// Record command execution for idempotent key exchange tracking
|
||
if (!opts?.skipRateCheck && tokenInfo.token) recordCommand(tokenInfo.token);
|
||
}
|
||
|
||
// Pin to a specific tab if requested (set by BROWSE_TAB env var, e.g. per-tab agent contexts).
|
||
// This prevents parallel agents from interfering with each other's tab context.
|
||
// Safe because Bun's event loop is single-threaded — no concurrent handleCommand.
|
||
let savedTabId: number | null = null;
|
||
if (tabId !== undefined && tabId !== null) {
|
||
savedTabId = browserManager.getActiveTabId();
|
||
// bringToFront: false — internal tab pinning must NOT steal window focus
|
||
try { browserManager.switchTab(tabId, { bringToFront: false }); } catch (err: any) {
|
||
console.warn('[browse] Failed to pin tab', tabId, ':', err.message);
|
||
}
|
||
}
|
||
|
||
// ─── Tab ownership check (own-only tokens / pair-agent isolation) ──
|
||
//
|
||
// Only `own-only` tokens (pair-agent over tunnel) are bound to their own
|
||
// tabs. `shared` tokens — the default for skill spawns and local scoped
|
||
// clients — can drive any tab; the capability gate (scope checks above)
|
||
// and rate limits already constrain what they can do.
|
||
//
|
||
// Skip for `newtab` — it creates a tab rather than accessing one.
|
||
if (command !== 'newtab' && tokenInfo && tokenInfo.clientId !== 'root' && tokenInfo.tabPolicy === 'own-only') {
|
||
const targetTab = tabId ?? browserManager.getActiveTabId();
|
||
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, { isWrite: isWriteInvocation(command, args), ownOnly: true })) {
|
||
return {
|
||
status: 403, json: true,
|
||
result: JSON.stringify({
|
||
error: 'Tab not owned by your agent. Use newtab to create your own tab.',
|
||
hint: `Tab ${targetTab} is owned by ${browserManager.getTabOwner(targetTab) || 'root'}. Your agent: ${tokenInfo.clientId}.`,
|
||
}),
|
||
};
|
||
}
|
||
}
|
||
|
||
// ─── newtab with ownership for scoped tokens ──────────────
|
||
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
|
||
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
|
||
return {
|
||
status: 200, json: true,
|
||
result: JSON.stringify({
|
||
tabId: newId,
|
||
owner: tokenInfo.clientId,
|
||
hint: 'Include "tabId": ' + newId + ' in subsequent commands to target this tab.',
|
||
}),
|
||
};
|
||
}
|
||
|
||
// Block mutation commands while watching (read-only observation mode).
|
||
// `--out` invocations count as mutations (they write the result to disk).
|
||
if (browserManager.isWatching() && isWriteInvocation(command, args)) {
|
||
return {
|
||
status: 400, json: true,
|
||
result: JSON.stringify({ error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.' }),
|
||
};
|
||
}
|
||
|
||
// Activity: emit command_start (skipped for chain subcommands)
|
||
const startTime = Date.now();
|
||
if (!opts?.skipActivity) {
|
||
emitActivity({
|
||
type: 'command_start',
|
||
command,
|
||
args,
|
||
url: browserManager.getCurrentUrl(),
|
||
tabs: browserManager.getTabCount(),
|
||
mode: browserManager.getConnectionMode(),
|
||
clientId: tokenInfo?.clientId,
|
||
});
|
||
}
|
||
|
||
try {
|
||
let result: string;
|
||
|
||
const session = browserManager.getActiveSession();
|
||
|
||
// Per-request warnings collected during hidden-element detection,
|
||
// surfaced into the envelope the LLM sees. Carries across the read
|
||
// phase into the centralized wrap block below.
|
||
let hiddenContentWarnings: string[] = [];
|
||
|
||
if (READ_COMMANDS.has(command)) {
|
||
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
|
||
// Hidden-element / ARIA-injection detection for every scoped
|
||
// DOM-reading channel (text, html, links, forms, accessibility,
|
||
// attrs, data, media, ux-audit). Previously only `text` received
|
||
// stripping; other channels let hidden injection payloads reach
|
||
// the LLM despite the envelope wrap. Detections become CONTENT
|
||
// WARNINGS on the outgoing envelope so the model can see what it
|
||
// would have otherwise trusted silently.
|
||
if (isScoped && DOM_CONTENT_COMMANDS.has(command)) {
|
||
const page = session.getPage();
|
||
try {
|
||
const strippedDescs = await markHiddenElements(page);
|
||
if (strippedDescs.length > 0) {
|
||
console.warn(`[browse] Content security: ${strippedDescs.length} hidden elements flagged on ${command} for ${tokenInfo.clientId}`);
|
||
hiddenContentWarnings = strippedDescs.slice(0, 8).map(d =>
|
||
`hidden content: ${d.slice(0, 120)}`,
|
||
);
|
||
if (strippedDescs.length > 8) {
|
||
hiddenContentWarnings.push(`hidden content: +${strippedDescs.length - 8} more flagged elements`);
|
||
}
|
||
}
|
||
if (command === 'text') {
|
||
const target = session.getActiveFrameOrPage();
|
||
result = await getCleanTextWithStripping(target);
|
||
} else {
|
||
result = await handleReadCommand(command, args, session, browserManager);
|
||
}
|
||
} finally {
|
||
await cleanupHiddenMarkers(page);
|
||
}
|
||
} else {
|
||
result = await handleReadCommand(command, args, session, browserManager);
|
||
}
|
||
} else if (WRITE_COMMANDS.has(command)) {
|
||
result = await handleWriteCommand(command, args, session, browserManager);
|
||
} else if (META_COMMANDS.has(command)) {
|
||
// Pass chain depth + executeCommand callback so chain routes subcommands
|
||
// through the full security pipeline (scope, domain, tab, wrapping).
|
||
const chainDepth = (opts?.chainDepth ?? 0);
|
||
// shutdown is factory-scoped (deleted from module scope in v1.35.0.0);
|
||
// route the call through activeShutdown which buildFetchHandler assigns.
|
||
const shutdownFn = () => activeShutdown ? activeShutdown() : Promise.resolve();
|
||
result = await handleMetaCommand(command, args, browserManager, shutdownFn, tokenInfo, {
|
||
chainDepth,
|
||
daemonPort: LOCAL_LISTEN_PORT,
|
||
executeCommand: (body, ti) => handleCommandInternal(body, ti, {
|
||
skipRateCheck: true, // chain counts as 1 request
|
||
skipActivity: true, // chain emits 1 event for all subcommands
|
||
chainDepth: chainDepth + 1, // recursion guard
|
||
}),
|
||
});
|
||
// Start periodic snapshot interval when watch mode begins
|
||
if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) {
|
||
const watchInterval = setInterval(async () => {
|
||
if (!browserManager.isWatching()) {
|
||
clearInterval(watchInterval);
|
||
return;
|
||
}
|
||
try {
|
||
const snapshot = await handleSnapshot(['-i'], browserManager.getActiveSession());
|
||
browserManager.addWatchSnapshot(snapshot);
|
||
} catch {
|
||
// Page may be navigating — skip this snapshot
|
||
}
|
||
}, 5000);
|
||
browserManager.watchInterval = watchInterval;
|
||
}
|
||
} else if (command === 'help') {
|
||
const helpText = generateHelpText();
|
||
return { status: 200, result: helpText };
|
||
} else {
|
||
// Use the rich unknown-command helper: names the input, suggests the closest
|
||
// match via Levenshtein (≤ 2 distance, ≥ 4 chars input), and appends an upgrade
|
||
// hint if the command is listed in NEW_IN_VERSION.
|
||
return {
|
||
status: 400, json: true,
|
||
result: JSON.stringify({
|
||
error: buildUnknownCommandError(rawCommand, ALL_COMMANDS),
|
||
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// ─── Centralized content wrapping (single location for all commands) ───
|
||
// Scoped tokens: content filter + enhanced envelope + datamarking
|
||
// Root tokens: basic untrusted content wrapper (backward compat)
|
||
// Chain exempt from top-level wrapping (each subcommand wrapped individually)
|
||
if (PAGE_CONTENT_COMMANDS.has(command) && command !== 'chain') {
|
||
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
|
||
if (isScoped) {
|
||
// Run content filters
|
||
const filterResult: ContentFilterResult = runContentFilters(
|
||
result, browserManager.getCurrentUrl(), command,
|
||
);
|
||
if (filterResult.blocked) {
|
||
return { status: 403, json: true, result: JSON.stringify({ error: filterResult.message }) };
|
||
}
|
||
// Datamark text command output only (not html, forms, or structured data)
|
||
if (command === 'text') {
|
||
result = datamarkContent(result);
|
||
}
|
||
// Enhanced envelope wrapping for scoped tokens.
|
||
// Merge per-request hidden-element warnings with content-filter
|
||
// warnings so both reach the LLM through the same CONTENT
|
||
// WARNINGS header.
|
||
const combinedWarnings = [...filterResult.warnings, ...hiddenContentWarnings];
|
||
result = wrapUntrustedPageContent(
|
||
result, command,
|
||
combinedWarnings.length > 0 ? combinedWarnings : undefined,
|
||
);
|
||
} else {
|
||
// Root token: basic wrapping (backward compat, Decision 2)
|
||
result = wrapUntrustedContent(result, browserManager.getCurrentUrl());
|
||
}
|
||
}
|
||
|
||
// Activity: emit command_end (skipped for chain subcommands)
|
||
const successDuration = Date.now() - startTime;
|
||
if (!opts?.skipActivity) {
|
||
emitActivity({
|
||
type: 'command_end',
|
||
command,
|
||
args,
|
||
url: browserManager.getCurrentUrl(),
|
||
duration: successDuration,
|
||
status: 'ok',
|
||
result: result,
|
||
tabs: browserManager.getTabCount(),
|
||
mode: browserManager.getConnectionMode(),
|
||
clientId: tokenInfo?.clientId,
|
||
});
|
||
}
|
||
|
||
writeAuditEntry({
|
||
ts: new Date().toISOString(),
|
||
cmd: command,
|
||
aliasOf: isAliased ? rawCommand : undefined,
|
||
args: args.join(' '),
|
||
origin: browserManager.getCurrentUrl(),
|
||
durationMs: successDuration,
|
||
status: 'ok',
|
||
hasCookies: browserManager.hasCookieImports(),
|
||
mode: browserManager.getConnectionMode(),
|
||
});
|
||
|
||
browserManager.resetFailures();
|
||
// Restore original active tab if we pinned to a specific one
|
||
if (savedTabId !== null) {
|
||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||
console.warn('[browse] Failed to restore tab after command:', restoreErr.message);
|
||
}
|
||
}
|
||
return { status: 200, result };
|
||
} catch (err: any) {
|
||
// Restore original active tab even on error
|
||
if (savedTabId !== null) {
|
||
try { browserManager.switchTab(savedTabId, { bringToFront: false }); } catch (restoreErr: any) {
|
||
console.warn('[browse] Failed to restore tab after error:', restoreErr.message);
|
||
}
|
||
}
|
||
|
||
// Activity: emit command_end (error) — skipped for chain subcommands
|
||
const errorDuration = Date.now() - startTime;
|
||
if (!opts?.skipActivity) {
|
||
emitActivity({
|
||
type: 'command_end',
|
||
command,
|
||
args,
|
||
url: browserManager.getCurrentUrl(),
|
||
duration: errorDuration,
|
||
status: 'error',
|
||
error: err.message,
|
||
tabs: browserManager.getTabCount(),
|
||
mode: browserManager.getConnectionMode(),
|
||
clientId: tokenInfo?.clientId,
|
||
});
|
||
}
|
||
|
||
writeAuditEntry({
|
||
ts: new Date().toISOString(),
|
||
cmd: command,
|
||
aliasOf: isAliased ? rawCommand : undefined,
|
||
args: args.join(' '),
|
||
origin: browserManager.getCurrentUrl(),
|
||
durationMs: errorDuration,
|
||
status: 'error',
|
||
error: err.message,
|
||
hasCookies: browserManager.hasCookieImports(),
|
||
mode: browserManager.getConnectionMode(),
|
||
});
|
||
|
||
browserManager.incrementFailures();
|
||
let errorMsg = wrapError(err);
|
||
const hint = browserManager.getFailureHint();
|
||
if (hint) errorMsg += '\n' + hint;
|
||
return { status: 500, result: JSON.stringify({ error: errorMsg }), json: true };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sanitizing wrapper around handleCommandInternalImpl. ALL callers (single-command
|
||
* HTTP, batch loop, scoped-token dispatch) go through this so the lone-surrogate
|
||
* sanitization happens once at the architectural choke point, not per-leaf.
|
||
* Do not bypass this by calling handleCommandInternalImpl directly.
|
||
*/
|
||
async function handleCommandInternal(
|
||
body: { command: string; args?: string[]; tabId?: number },
|
||
tokenInfo?: TokenInfo | null,
|
||
opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number },
|
||
): Promise<CommandResult> {
|
||
const cr = await handleCommandInternalImpl(body, tokenInfo, opts);
|
||
return { ...cr, result: stripLoneSurrogates(cr.result) };
|
||
}
|
||
|
||
/**
|
||
* Build the HTTP response from a CommandResult. Pure function so it can be
|
||
* unit-tested without spinning up the server (#1440). Defense in depth on top
|
||
* of handleCommandInternal's choke-point sanitization: this catches any
|
||
* \uXXXX JSON-escape surrogate forms that the raw-codepoint regex above
|
||
* misses when the body has already been JSON-stringified.
|
||
*/
|
||
export function buildCommandResponse(cr: CommandResult): Response {
|
||
const contentType = cr.json ? 'application/json' : 'text/plain';
|
||
const safeBody = typeof cr.result === 'string' ? sanitizeBody(cr.result, !!cr.json) : cr.result;
|
||
return new Response(safeBody, {
|
||
status: cr.status,
|
||
headers: { 'Content-Type': contentType, ...cr.headers },
|
||
});
|
||
}
|
||
|
||
/** HTTP wrapper — converts CommandResult to Response. Used by the /command
|
||
* route (routes/commands.ts, via RouteContext). The wrapper layer exists so
|
||
* `buildCommandResponse` is independently unit-testable (v1.38.1.0).
|
||
*/
|
||
async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<Response> {
|
||
const cr = await handleCommandInternal(body, tokenInfo);
|
||
return buildCommandResponse(cr);
|
||
}
|
||
|
||
// Module-level shutdown function deleted in v1.39.0.0; it now lives inside
|
||
// the buildFetchHandler closure so it closes the cfg-provided browserManager.
|
||
// Signal handlers below call activeShutdown which buildFetchHandler assigns.
|
||
|
||
// Handle signals
|
||
//
|
||
// Node passes the signal name (e.g. 'SIGTERM') as the first arg to listeners.
|
||
// Wrap calls so activeShutdown receives no args — otherwise the string gets
|
||
// passed as exitCode and process.exit() coerces it to NaN, exiting with code 1
|
||
// instead of 0. (Caught in v0.18.1.0 #1025.)
|
||
//
|
||
// Gated on `import.meta.main` so embedders (gbrowser phoenix) that import
|
||
// server.ts as a submodule can register their own signal handlers without
|
||
// fighting with gstack's. CLI path is unchanged.
|
||
if (import.meta.main) {
|
||
// Standalone daemon: a Chromium crash must exit THIS process (its
|
||
// supervisor/user notices); embedders and in-process test launches must
|
||
// never be exited by browser-manager's disconnect handler.
|
||
markDaemonProcess();
|
||
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||
process.on('SIGINT', () => activeShutdown?.());
|
||
// SIGHUP (terminal hangup): with handleSIGHUP:false at the three launch
|
||
// sites (#2220), Playwright no longer closes Chromium when this process
|
||
// gets hung up on — this handler is now the ONLY Chromium cleanup on
|
||
// SIGHUP (ENG-OV4). Route to the same shutdown path as SIGINT:
|
||
// activeShutdown closes the browser, releases ports, and removes the
|
||
// state file. Without it, a hangup would leak a live Chromium.
|
||
process.on('SIGHUP', () => activeShutdown?.());
|
||
// SIGTERM behavior depends on mode:
|
||
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
|
||
// parent shell exits between tool invocations. Ignoring it keeps the server
|
||
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
|
||
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
|
||
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
||
// without waiting forever. Ctrl+C and /stop still work either way.
|
||
// - Active cookie picker: never tear down mid-import regardless of mode —
|
||
// would strand the picker UI with "Failed to fetch."
|
||
process.on('SIGTERM', () => {
|
||
if (hasActivePicker()) {
|
||
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
|
||
return;
|
||
}
|
||
const headed = activeBrowserManager.getConnectionMode() === 'headed';
|
||
if (headed || tunnelActive) {
|
||
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||
activeShutdown?.();
|
||
} else {
|
||
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
|
||
}
|
||
});
|
||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||
if (process.platform === 'win32') {
|
||
process.on('exit', removeOwnedDaemonStateQuiet);
|
||
}
|
||
}
|
||
|
||
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
|
||
function emergencyCleanup() {
|
||
if (isShuttingDown) return;
|
||
isShuttingDown = true;
|
||
// Xvfb cleanup MUST happen before state-file deletion. spawnXvfb detaches
|
||
// the child, so without this, an uncaught exception leaves the Xvfb
|
||
// running with no PID record — orphan accumulates and eventually
|
||
// exhausts the :99-:120 display range. Read the state file FIRST,
|
||
// call cleanupXvfb (validates cmdline + start-time before kill), THEN
|
||
// delete the state file.
|
||
try {
|
||
if (fs.existsSync(config.stateFile)) {
|
||
const raw = fs.readFileSync(config.stateFile, 'utf-8');
|
||
const state = JSON.parse(raw);
|
||
if (state.pid !== process.pid || state.instanceId !== SERVER_INSTANCE_ID) return;
|
||
if (state.xvfbPid && state.xvfbStartTime) {
|
||
// Lazy import — emergencyCleanup may run on platforms where
|
||
// ./xvfb's Linux-specific helpers fail to load. Best effort.
|
||
try {
|
||
const { cleanupXvfb } = require('./xvfb');
|
||
cleanupXvfb({
|
||
pid: state.xvfbPid,
|
||
startTime: state.xvfbStartTime,
|
||
display: state.xvfbDisplay || ':99',
|
||
});
|
||
} catch { /* best effort */ }
|
||
}
|
||
}
|
||
} catch { /* state file unparseable — fall through to lock + state cleanup */ }
|
||
|
||
// Clean Chromium profile locks via the shared helper (defensive guard
|
||
// refuses to operate on unrecognized profile dirs).
|
||
if (activeBrowserManager.getConnectionMode() === 'headed' || process.env.BROWSE_HEADED === '1') {
|
||
cleanSingletonLocks(resolveChromiumProfile());
|
||
}
|
||
removeOwnedDaemonStateQuiet();
|
||
}
|
||
// Same import.meta.main gate as SIGINT/SIGTERM — embedders register their
|
||
// own crash handlers.
|
||
if (import.meta.main) {
|
||
process.on('uncaughtException', (err) => {
|
||
console.error('[browse] FATAL uncaught exception:', err.message);
|
||
emergencyCleanup();
|
||
process.exit(1);
|
||
});
|
||
process.on('unhandledRejection', (err: any) => {
|
||
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
|
||
emergencyCleanup();
|
||
process.exit(1);
|
||
});
|
||
}
|
||
|
||
// ─── Start ─────────────────────────────────────────────────────
|
||
/**
|
||
* Entry point for `bun run dev` and the compiled binary.
|
||
*
|
||
* Exported so embedders (gbrowser phoenix overlay) can call it
|
||
* directly with env vars set, bypassing the module-level `import.meta.main`
|
||
* gate. Phoenix's eventual fd-passing path will use `buildFetchHandler`
|
||
* directly; until that lands, calling `start()` from a non-main entry is
|
||
* supported via env (AUTH_TOKEN, BROWSE_PORT, BROWSE_OWN_SIGNALS).
|
||
*/
|
||
/**
|
||
* Build a request handler set for the browse daemon. Embedders (gbrowser
|
||
* phoenix overlay) call this directly with their own cfg to compose overlay
|
||
* routes via cfg.beforeRoute, pass a pre-launched cfg.browserManager, and
|
||
* opt out of terminal-agent teardown via cfg.ownsTerminalAgent (default
|
||
* true, set to false when the embedder runs its own PTY server). The CLI
|
||
* path calls this through start() with env-derived defaults and explicit
|
||
* cfg.ownsTerminalAgent: true — externally-observable behavior is identical.
|
||
*
|
||
* Auth state lives ENTIRELY inside the factory closure: cfg.authToken is the
|
||
* single source of truth for the bearer secret, factory-scoped validateAuth
|
||
* closes over it, and factory-scoped shutdown closes the cfg-provided
|
||
* browserManager. Module-level lifecycle singletons (LOCAL_LISTEN_PORT,
|
||
* tunnelActive, inspector state) intentionally STAY at module scope; see
|
||
* the v1.35.0.0 CHANGELOG entry for the architectural rationale.
|
||
*
|
||
* The returned ServerHandle is callable directly. Bun.serve is the caller's
|
||
* responsibility — embedders may fd-pass; CLI uses Bun.serve normally.
|
||
*/
|
||
export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||
if (!cfg.authToken || cfg.authToken.length < 16) {
|
||
throw new Error('buildFetchHandler: cfg.authToken must be a non-empty string >= 16 chars');
|
||
}
|
||
if (!cfg.browserManager) {
|
||
throw new Error('buildFetchHandler: cfg.browserManager is required');
|
||
}
|
||
|
||
// Re-run init with cfg-provided values. ensureStateDir is idempotent
|
||
// (mkdir -p); initAuditLog is idempotent (sets a module string);
|
||
// initRegistry is idempotent for same-token, throws for different-token.
|
||
// Owning init here (instead of at module load) means cfg.authToken is the
|
||
// single source of truth for the registry root token.
|
||
ensureStateDir(cfg.config);
|
||
initAuditLog(cfg.config.auditLog);
|
||
initRegistry(cfg.authToken);
|
||
|
||
const { authToken, browserManager: cfgBrowserManager, startTime, beforeRoute, browsePort } = cfg;
|
||
// Strict opt-out: only explicit `false` flips the gate. Any other value
|
||
// (undefined, truthy non-bool from a JS caller bypassing TS, etc.) defaults
|
||
// to gstack-owns. Matches the "default-true preserves CLI bit-for-bit"
|
||
// premise even under malformed cfg.
|
||
const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true;
|
||
|
||
// ─── Terminal-Agent Watchdog (v1.44+) ─────────────────────────────
|
||
//
|
||
// The terminal-agent process can die independently of the server: SIGKILL
|
||
// from the OS OOM killer, an uncaught exception under load, an external
|
||
// `pkill` from a sibling debugging session. Pre-v1.44 the sidebar would
|
||
// see the broken connection and stay broken until the user reloaded.
|
||
// Now: 60s ticker checks the recorded agent PID, respawns via the shared
|
||
// spawnTerminalAgent helper if dead.
|
||
//
|
||
// Identity-based — uses readAgentRecord + isProcessAlive, NOT a process
|
||
// name probe. Critical: prevents respawning around a slow-but-alive agent
|
||
// (which would create split-brain — two agents writing the port file,
|
||
// tokens diverging between them, mystery PTY upgrade failures).
|
||
//
|
||
// Crash-loop guard: 3 respawn attempts inside 60s → stop trying and emit
|
||
// a one-line error. Manual `forceRestart` from the sidebar clears the
|
||
// history (the user is the explicit signal to retry).
|
||
//
|
||
// Only active when ownsTerminalAgent === true. Embedders that pre-launch
|
||
// their own PTY server (gbrowser phoenix overlay) must not be auto-respawned
|
||
// by us — their lifecycle is their concern.
|
||
let agentWatchdogInterval: ReturnType<typeof setInterval> | null = null;
|
||
const respawnHistory: number[] = [];
|
||
const AGENT_WATCHDOG_TICK_MS = parseInt(
|
||
process.env.GSTACK_AGENT_WATCHDOG_TICK_MS || '60000',
|
||
10,
|
||
);
|
||
const RESPAWN_GUARD_MAX = 3;
|
||
// The guard window MUST span enough ticks for RESPAWN_GUARD_MAX respawns to
|
||
// land inside it. This was a fixed 60_000 against a 60_000 tick, so at most
|
||
// ONE respawn could ever be in the window and `respawnHistory.length >= 3`
|
||
// was unreachable — the guard could not fire at the default tick rate, and a
|
||
// steady one-per-tick leak ran unbounded instead of stopping after 3. Scale
|
||
// with the tick so the intent ("3 crashes in quick succession → stop") holds
|
||
// at any tick value: 3 respawns within 5 ticks trips it.
|
||
const RESPAWN_GUARD_WINDOW_MS = Math.max(
|
||
60_000,
|
||
AGENT_WATCHDOG_TICK_MS * (RESPAWN_GUARD_MAX + 2),
|
||
);
|
||
let agentRespawnGuardTripped = false;
|
||
let consecutiveSpawnFailures = 0;
|
||
|
||
if (ownsTerminalAgent) {
|
||
agentWatchdogInterval = setInterval(() => {
|
||
if (isShuttingDown) return;
|
||
if (agentRespawnGuardTripped) return;
|
||
const stateDir = path.dirname(cfg.config.stateFile);
|
||
const record = readAgentRecord(stateDir);
|
||
// If the record exists and the PID is alive, the agent is healthy
|
||
// (or at least still answering signal 0). Slow-but-alive agents
|
||
// intentionally fall through here — split-brain is worse than
|
||
// unresponsiveness, and slow recovery is handled by the user via
|
||
// restart.
|
||
if (record && !isAgentRecordGone(record)) return;
|
||
// Either no record (never spawned, or cleaned up after crash) or
|
||
// PID is dead. Try to respawn.
|
||
const now = Date.now();
|
||
while (respawnHistory.length && now - respawnHistory[0] > RESPAWN_GUARD_WINDOW_MS) {
|
||
respawnHistory.shift();
|
||
}
|
||
if (respawnHistory.length >= RESPAWN_GUARD_MAX) {
|
||
agentRespawnGuardTripped = true;
|
||
console.error(
|
||
`[browse] terminal-agent respawn guard tripped (${RESPAWN_GUARD_MAX} crashes in ${RESPAWN_GUARD_WINDOW_MS / 1000}s) — manual restart required`,
|
||
);
|
||
return;
|
||
}
|
||
respawnHistory.push(now);
|
||
try {
|
||
const pid = spawnTerminalAgent({
|
||
stateFile: cfg.config.stateFile,
|
||
serverPort: cfg.browsePort,
|
||
ownerPid: process.pid,
|
||
cwd: cfg.config.projectDir,
|
||
});
|
||
if (pid) {
|
||
consecutiveSpawnFailures = 0;
|
||
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
|
||
} else {
|
||
consecutiveSpawnFailures++;
|
||
console.warn('[browse] terminal-agent respawn skipped — script not found on disk');
|
||
}
|
||
} catch (err: any) {
|
||
consecutiveSpawnFailures++;
|
||
console.warn('[browse] terminal-agent respawn failed:', err?.message || err);
|
||
}
|
||
if (consecutiveSpawnFailures >= RESPAWN_GUARD_MAX) {
|
||
agentRespawnGuardTripped = true;
|
||
console.error('[browse] terminal-agent respawn guard tripped after repeated failed starts — manual restart required');
|
||
}
|
||
}, AGENT_WATCHDOG_TICK_MS);
|
||
// Detach the watchdog timer from Node's event-loop ref count so a
|
||
// healthy idle process can still exit cleanly if everything else is
|
||
// also unref'd. Bun's setInterval returns a Timer with unref().
|
||
(agentWatchdogInterval as any)?.unref?.();
|
||
}
|
||
|
||
// Factory-scoped validateAuth. Closes over cfg.authToken so every internal
|
||
// auth check sees the same token the routes receive. Module-level
|
||
// validateAuth was deleted in v1.35.0.0.
|
||
function validateAuth(req: Request): boolean {
|
||
const header = req.headers.get('authorization');
|
||
if (header === null) return false;
|
||
// Constant-time compare so a byte-by-byte early-exit can't leak the token
|
||
// prefix via response timing. timingSafeEqual requires equal-length inputs,
|
||
// so the length check gates it (the length itself is not secret).
|
||
const got = Buffer.from(header);
|
||
const want = Buffer.from(`Bearer ${authToken}`);
|
||
return got.length === want.length && crypto.timingSafeEqual(got, want);
|
||
}
|
||
|
||
// Factory-scoped shutdown. Closes the cfg-provided browserManager so
|
||
// embedders that pass their own BrowserManager get correct teardown.
|
||
// Module-level shutdown was deleted in v1.35.0.0.
|
||
async function shutdown(exitCode: number = 0) {
|
||
if (isShuttingDown) return;
|
||
isShuttingDown = true;
|
||
// State and terminal files belong to this instance, including embedders
|
||
// whose cfg differs from the standalone daemon's module-level config.
|
||
const config = cfg.config;
|
||
const stateOwner = (() => {
|
||
try { return JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch { return null; }
|
||
})();
|
||
const foreignState = Number.isSafeInteger(stateOwner?.pid) && stateOwner.pid > 0
|
||
&& (stateOwner.pid !== process.pid || (stateOwner.instanceId && stateOwner.instanceId !== SERVER_INSTANCE_ID));
|
||
|
||
console.log('[browse] Shutting down...');
|
||
if (ownsTerminalAgent && !foreignState) {
|
||
// Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f
|
||
// terminal-agent\.ts` regex teardown which matched sibling gstack
|
||
// sessions on the same host. Only the PID recorded in
|
||
// `<stateDir>/terminal-agent-pid` by THIS daemon's agent is signaled.
|
||
try {
|
||
const stateDir = path.dirname(config.stateFile);
|
||
const releaseAgentLock = acquireAgentStateLock(stateDir);
|
||
try {
|
||
let currentState: { pid?: number; instanceId?: string } | null = null;
|
||
try { currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {}
|
||
if (currentState?.pid && (currentState.pid !== process.pid
|
||
|| (currentState.instanceId && currentState.instanceId !== SERVER_INSTANCE_ID))) {
|
||
console.warn('[browse] terminal-agent state now belongs to a successor; retaining its files');
|
||
} else {
|
||
const record = readAgentRecord(stateDir);
|
||
const agentStopped = !record || (record.pid !== 0
|
||
&& (!isAgentRecordLive(record) || (isOurAgent(record, process.pid) && stopAgentByRecord(record))));
|
||
const current = readAgentRecord(stateDir);
|
||
if (agentStopped && (!record || (current?.pid === record.pid && current.gen === record.gen))) {
|
||
safeUnlinkQuiet(path.join(stateDir, 'terminal-port'));
|
||
safeUnlinkQuiet(path.join(stateDir, 'terminal-internal-token'));
|
||
if (record) clearAgentRecord(stateDir, record);
|
||
} else if (!agentStopped) {
|
||
console.warn('[browse] terminal-agent identity or exit could not be confirmed; retaining its record');
|
||
}
|
||
}
|
||
} finally { releaseAgentLock(); }
|
||
} catch (err: any) {
|
||
console.warn('[browse] Failed to stop terminal-agent; retaining its state:', err.message);
|
||
}
|
||
}
|
||
try { detachSession(); } catch (err: any) {
|
||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||
}
|
||
clearInspectorSubscribers();
|
||
if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch();
|
||
clearInterval(flushInterval);
|
||
clearInterval(idleCheckInterval);
|
||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||
// Stop the session-persist ticker BEFORE the final snapshot below —
|
||
// paired with the isShuttingDown gate inside the tick, this guarantees
|
||
// no interval snapshot can race the final one during teardown.
|
||
if (sessionPersistInterval) {
|
||
clearInterval(sessionPersistInterval);
|
||
sessionPersistInterval = null;
|
||
}
|
||
await flushBuffers();
|
||
|
||
// Final session snapshot before the browser goes away (#778). Best
|
||
// effort with a hard 2s deadline: shutdown must never hang on a wedged
|
||
// page.evaluate — after the deadline we proceed to browser close and let
|
||
// the previous interval snapshot stand (atomic writes guarantee it's
|
||
// intact). The .catch is attached to the persist promise itself so a
|
||
// late rejection after losing the race can't become an unhandled
|
||
// rejection.
|
||
if (isSessionPersistEnabled()) {
|
||
const finalSnapshot = persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE))
|
||
.catch((err: any) => {
|
||
console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`);
|
||
});
|
||
await Promise.race([
|
||
finalSnapshot,
|
||
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
|
||
]);
|
||
}
|
||
|
||
await cfgBrowserManager.close();
|
||
|
||
if (cfgBrowserManager.getConnectionMode() === 'headed') {
|
||
cleanSingletonLocks(resolveChromiumProfile());
|
||
}
|
||
if (!foreignState) {
|
||
try {
|
||
const releaseStateLock = acquireAgentStateLock(path.dirname(config.stateFile));
|
||
try {
|
||
const currentState = JSON.parse(fs.readFileSync(config.stateFile, 'utf8'));
|
||
if (currentState.pid === process.pid && currentState.instanceId === SERVER_INSTANCE_ID) safeUnlinkQuiet(config.stateFile);
|
||
} finally { releaseStateLock(); }
|
||
} catch (err: any) {
|
||
if (fs.existsSync(config.stateFile)) console.warn('[browse] Daemon state cleanup could not confirm ownership:', err?.message || err);
|
||
}
|
||
}
|
||
process.exit(exitCode);
|
||
}
|
||
|
||
// Named lifecycle helper (matches closeTunnel style). Logs failures so
|
||
// future debugging isn't blind to a stuck listener.
|
||
async function stopListeners(local: any, tunnel?: any) {
|
||
try { if (local?.stop) local.stop(true); }
|
||
catch (err: any) { console.warn('[browse] local listener stop failed:', err?.message || err); }
|
||
try { if (tunnel?.stop) tunnel.stop(true); }
|
||
catch (err: any) { console.warn('[browse] tunnel listener stop failed:', err?.message || err); }
|
||
}
|
||
|
||
// Register this handle's shutdown as the active one. Module-level
|
||
// handlers (idleCheckInterval, parent watchdog, onDisconnect, signal
|
||
// handlers) call activeShutdown so they reach THIS shutdown, not a stale
|
||
// module reference. Critical for embedders whose cfg.browserManager
|
||
// differs from the module-level instance.
|
||
activeShutdown = shutdown;
|
||
|
||
// Retarget the BrowserManager indirection at the cfg-instance so the
|
||
// module-level idleCheckTick + parent watchdog + SIGTERM handler all read
|
||
// the right connectionMode. Without this, headed embedders auto-shutdown
|
||
// after 30 min of HTTP idle because the dead module-level instance still
|
||
// reports connectionMode === 'launched'.
|
||
activeBrowserManager = cfgBrowserManager;
|
||
// Same reason as above: the watchdog reads activeBrowserManager, so the
|
||
// instance that can promote itself to headed must be the one that can
|
||
// suppress the headed parent-death branch. An embedder-supplied manager
|
||
// otherwise promotes silently and the watchdog keeps shutting down on a
|
||
// promotion it can no longer see.
|
||
cfgBrowserManager.onHeadedPromotion = () => suppressHeadedParentShutdown(cfg.config, cfgBrowserManager);
|
||
|
||
// Wire the cfg-instance's onDisconnect to run shutdown when the user
|
||
// closes the headed browser window. CHAIN any caller-provided handler
|
||
// instead of overwriting it: gbrowser may have set its own onDisconnect
|
||
// before calling buildFetchHandler (e.g. for snapshot/log work that needs
|
||
// to run before the process exits). Caller errors are logged but never
|
||
// block gstack shutdown — defensive symmetry with the safeUnlinkQuiet /
|
||
// safeKill philosophy in error-handling.ts.
|
||
const callerOnDisconnect = cfgBrowserManager.onDisconnect;
|
||
cfgBrowserManager.onDisconnect = async (code) => {
|
||
if (callerOnDisconnect) {
|
||
try { await callerOnDisconnect(code); }
|
||
catch (err: any) {
|
||
console.warn('[browse] caller onDisconnect threw:', err?.message ?? err);
|
||
}
|
||
}
|
||
await activeShutdown?.(code ?? 2);
|
||
};
|
||
|
||
// Everything a route handler may use. Handlers get the cfg-provided
|
||
// BrowserManager and auth checks as functions; the raw token reaches only
|
||
// the two bootstrap routes that hand it on (see RouteContext).
|
||
const routeCtx: RouteContext = {
|
||
browserManager: cfgBrowserManager,
|
||
startTime,
|
||
browsePort,
|
||
validateAuth,
|
||
isRootRequest,
|
||
getTokenInfo,
|
||
hasSseCookie: (req) => validateSseSessionToken(extractSseCookie(req)),
|
||
isPinnedExtensionRequest,
|
||
isRootTokenValue: (token) => token !== null && token === authToken,
|
||
bootstrapRootToken: authToken,
|
||
resetIdleTimer,
|
||
terminal: { readPort: readTerminalPort, grantToken: grantPtyToken, restartSession: restartPtySession },
|
||
tunnel: {
|
||
state: () => ({ active: tunnelActive, url: tunnelUrl, hasListener: tunnelServer !== null }),
|
||
close: closeTunnel,
|
||
resolveAuthtoken: resolveNgrokAuthtoken,
|
||
start: (authtoken) => startTunnel({
|
||
fetchHandler: makeFetchHandler('tunnel'),
|
||
authtoken,
|
||
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
|
||
}),
|
||
},
|
||
commands: {
|
||
handle: handleCommand,
|
||
handleInternal: (body, tokenInfo, opts) => handleCommandInternal(body, tokenInfo, opts),
|
||
},
|
||
};
|
||
|
||
const makeFetchHandler = (surface: Surface) => async (req: Request): Promise<Response> => {
|
||
const url = new URL(req.url);
|
||
|
||
// ─── Tunnel surface filter (runs before any route dispatch) ──
|
||
if (surface === 'tunnel') {
|
||
const isGetConnect = req.method === 'GET' && url.pathname === '/connect';
|
||
const allowed = TUNNEL_PATHS.has(url.pathname);
|
||
if (!allowed && !isGetConnect) {
|
||
logTunnelDenial(req, url, 'path_not_on_tunnel');
|
||
return new Response(JSON.stringify({ error: 'Not found' }), {
|
||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
if (isRootRequest(req)) {
|
||
logTunnelDenial(req, url, 'root_token_on_tunnel');
|
||
return new Response(JSON.stringify({
|
||
error: 'Root token rejected on tunnel surface',
|
||
hint: 'Remote agents must pair via /connect to receive a scoped token.',
|
||
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
if (url.pathname !== '/connect' && !getTokenInfo(req)) {
|
||
logTunnelDenial(req, url, 'missing_scoped_token');
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// beforeRoute overlay hook (v1.35.0.0). Runs AFTER the tunnel surface
|
||
// filter and BEFORE per-route dispatch. Pre-resolves bearer auth once
|
||
// so the hook receives TokenInfo | null. Note: getTokenInfo returns null
|
||
// for both missing AND invalid bearer — see the ServerConfig.beforeRoute
|
||
// JSDoc for the security implications.
|
||
if (beforeRoute) {
|
||
const auth = getTokenInfo(req);
|
||
const overlayResp = await beforeRoute(req, surface, auth);
|
||
if (overlayResp) return overlayResp;
|
||
}
|
||
|
||
return dispatchRoute(ROUTES, req, url, surface, routeCtx);
|
||
};
|
||
|
||
return {
|
||
fetchLocal: makeFetchHandler('local'),
|
||
fetchTunnel: makeFetchHandler('tunnel'),
|
||
shutdown,
|
||
stopListeners,
|
||
};
|
||
}
|
||
|
||
export async function start() {
|
||
// Clear old log files
|
||
safeUnlink(CONSOLE_LOG_PATH);
|
||
safeUnlink(NETWORK_LOG_PATH);
|
||
safeUnlink(DIALOG_LOG_PATH);
|
||
|
||
const port = await findPort();
|
||
LOCAL_LISTEN_PORT = port;
|
||
|
||
// ─── Proxy config (D8 + codex F5) ──────────────────────────────
|
||
// BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5
|
||
// with auth, we run a local 127.0.0.1 bridge that relays to the
|
||
// authenticated upstream (Chromium can't do SOCKS5 auth itself). For
|
||
// HTTP/HTTPS or unauthenticated SOCKS5, we pass the URL directly to
|
||
// Chromium's proxy.server option.
|
||
let proxyBridge: BridgeHandle | null = null;
|
||
const proxyUrl = process.env.BROWSE_PROXY_URL;
|
||
if (proxyUrl) {
|
||
let parsed;
|
||
try {
|
||
parsed = parseProxyConfig({
|
||
proxyUrl,
|
||
envUser: process.env.BROWSE_PROXY_USER,
|
||
envPass: process.env.BROWSE_PROXY_PASS,
|
||
});
|
||
} catch (err) {
|
||
if (err instanceof ProxyConfigError) {
|
||
console.error(`[browse] error: ${err.message} (${err.hint})`);
|
||
process.exit(1);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
if (parsed.scheme === 'socks5' && parsed.hasAuth) {
|
||
// Pre-flight: verify upstream accepts our creds before launching
|
||
// Chromium. 5s budget, 3 retries with 500ms backoff (D4: handles VPN
|
||
// warm-up race). On failure, exit with redacted error.
|
||
console.log(`[browse] Testing SOCKS5 upstream ${redactProxyUrl(proxyUrl)}...`);
|
||
try {
|
||
const test = await testUpstream({
|
||
upstream: toUpstreamConfig(parsed),
|
||
budgetMs: 5000,
|
||
retries: 3,
|
||
backoffMs: 500,
|
||
});
|
||
console.log(`[browse] [proxy] upstream test ok in ${test.ms}ms (${test.attempts} attempt${test.attempts === 1 ? '' : 's'})`);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error(`[browse] [proxy] FAIL upstream ${redactProxyUrl(proxyUrl)}: ${msg}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
proxyBridge = await startSocksBridge({ upstream: toUpstreamConfig(parsed) });
|
||
console.log(`[browse] [proxy] bridge listening on 127.0.0.1:${proxyBridge.port}`);
|
||
browserManager.setProxyConfig({ server: `socks5://127.0.0.1:${proxyBridge.port}` });
|
||
} else {
|
||
// HTTP/HTTPS or unauth SOCKS5 — pass through to Chromium directly.
|
||
browserManager.setProxyConfig({
|
||
server: `${parsed.scheme}://${parsed.host}:${parsed.port}`,
|
||
...(parsed.userId ? { username: parsed.userId } : {}),
|
||
...(parsed.password ? { password: parsed.password } : {}),
|
||
});
|
||
console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} (pass-through to Chromium)`);
|
||
}
|
||
|
||
// Tear down bridge on shutdown.
|
||
process.on('exit', () => {
|
||
if (proxyBridge) {
|
||
proxyBridge.close().catch(() => { /* shutting down anyway */ });
|
||
}
|
||
});
|
||
}
|
||
|
||
process.on('exit', () => { browserManager.closeOwnedDisplay(); });
|
||
|
||
// Read env once — single source of truth for authToken (and other env).
|
||
// Threaded through launchHeaded, buildFetchHandler, and the state file
|
||
// write so all consumers see the same value. v1.34.x's module-level
|
||
// AUTH_TOKEN const was deleted in v1.35.0.0.
|
||
const envCfg = resolveConfigFromEnv();
|
||
|
||
// Launch browser (headless or headed with extension)
|
||
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
|
||
const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1';
|
||
if (!skipBrowser) {
|
||
const headed = process.env.BROWSE_HEADED === '1';
|
||
if (headed) {
|
||
await browserManager.launchHeaded(envCfg.authToken);
|
||
console.log(`[browse] Launched headed Chromium with extension`);
|
||
} else {
|
||
await browserManager.launch();
|
||
}
|
||
}
|
||
|
||
const startTime = Date.now();
|
||
|
||
// ─── Build the request handlers via buildFetchHandler factory ───
|
||
// CLI path passes env-derived values; no beforeRoute hook. Phoenix uses
|
||
// the same factory with its own cfg + overlay hook.
|
||
const handle = buildFetchHandler({
|
||
...envCfg,
|
||
browsePort: port, // actual bound port (resolveConfigFromEnv default is 0)
|
||
browserManager, // module-level instance, same as today
|
||
proxyBridge,
|
||
startTime,
|
||
ownsTerminalAgent: true, // CLI spawns terminal-agent.ts itself (see cli.ts:1037-1063)
|
||
});
|
||
|
||
const server = Bun.serve({
|
||
port,
|
||
hostname: '127.0.0.1',
|
||
fetch: handle.fetchLocal,
|
||
});
|
||
|
||
browserManager.serverPort = port;
|
||
|
||
// Navigate to welcome page if in headed mode and still on about:blank
|
||
if (browserManager.getConnectionMode() === 'headed') {
|
||
try {
|
||
const currentUrl = browserManager.getCurrentUrl();
|
||
if (currentUrl === 'about:blank' || currentUrl === '') {
|
||
const page = browserManager.getPage();
|
||
await page.goto(`http://127.0.0.1:${port}/welcome`, { timeout: 3000 }).catch((err: any) => {
|
||
console.warn('[browse] Failed to navigate to welcome page:', err.message);
|
||
});
|
||
}
|
||
} catch (err: any) {
|
||
console.warn('[browse] Welcome page navigation setup failed:', err.message);
|
||
}
|
||
}
|
||
|
||
if (isShuttingDown) return;
|
||
|
||
// Write state file (atomic: write .tmp then rename)
|
||
const xvfb = browserManager.getXvfbHandle();
|
||
const state: Record<string, unknown> = {
|
||
pid: process.pid,
|
||
instanceId: SERVER_INSTANCE_ID,
|
||
port,
|
||
token: envCfg.authToken,
|
||
startedAt: new Date().toISOString(),
|
||
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
||
binaryVersion: readVersionHash() || undefined,
|
||
mode: browserManager.getConnectionMode(),
|
||
// D2 daemon-mismatch detection: CLI computes the same hash from its
|
||
// resolved flags and refuses if it differs from this stored value.
|
||
...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}),
|
||
// Xvfb child PID + start-time + display so disconnect (or a future
|
||
// daemon launch on this state file) can validate-then-cleanup orphans
|
||
// without clobbering a recycled PID.
|
||
...(xvfb ? { xvfbPid: xvfb.pid, xvfbStartTime: xvfb.startTime, xvfbDisplay: xvfb.display } : {}),
|
||
// #2709: launched-Chromium identity (pid + start time) so `browse stop`
|
||
// can reap a survivor — the headless launch has no SingletonLock for
|
||
// killOrphanChromium to walk, and on macOS 26 the orphaned GPU process
|
||
// kept spinning at ~800% CPU after the daemon exited.
|
||
...(() => {
|
||
const info = browserManager.getChromiumProcInfo();
|
||
return info ? { chromiumPid: info.pid, chromiumStartTime: info.startTime } : {};
|
||
})(),
|
||
};
|
||
const tmpFile = tmpStatePath();
|
||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||
try {
|
||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||
try { fs.renameSync(tmpFile, config.stateFile); } finally { releaseStateLock(); }
|
||
} catch (err) {
|
||
safeUnlinkQuiet(tmpFile);
|
||
throw err;
|
||
}
|
||
|
||
const stateWatchMs = parseInt(process.env.GSTACK_STATE_WATCH_MS || '60000', 10);
|
||
if (stateWatchMs > 0) {
|
||
let missed = 0;
|
||
const stateWatch = setInterval(() => {
|
||
let owner: { pid?: number; instanceId?: string } | null = null;
|
||
try { owner = JSON.parse(fs.readFileSync(config.stateFile, 'utf8')); } catch {}
|
||
if (owner?.pid === process.pid && owner.instanceId === SERVER_INSTANCE_ID) { missed = 0; return; }
|
||
if (++missed < 2) return;
|
||
clearInterval(stateWatch);
|
||
console.warn('[browse] daemon state is no longer reachable; shutting down this instance');
|
||
handle.shutdown();
|
||
}, stateWatchMs);
|
||
(stateWatch as any).unref?.();
|
||
}
|
||
|
||
// ─── Opt-in session persistence (#778 class) ─────────────────
|
||
// BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last
|
||
// snapshot, then keep snapshotting on an interval. Launched mode only —
|
||
// the headed persistent profile owns its own state. The final snapshot at
|
||
// clean shutdown lives in buildFetchHandler's shutdown().
|
||
//
|
||
// Runs AFTER Bun.serve() + the state-file write, in the BACKGROUND:
|
||
// restore re-creates tabs sequentially with up-to-15s goto timeouts while
|
||
// the CLI's readiness probe gives up at 8s — one slow/unreachable saved
|
||
// URL must never make every `$B` command report "Server failed to start".
|
||
// Fire-and-forget: a restore failure is logged and never affects the
|
||
// daemon.
|
||
if (!skipBrowser && isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') {
|
||
const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE);
|
||
restoreSessionState(browserManager, sessionStatePath)
|
||
.then((restored) => {
|
||
if (restored) {
|
||
// Counts come from the deserialized snapshot itself — no extra
|
||
// saveState() round-trip against pages that may still be loading.
|
||
console.log(`[browse] Session state restored: ${restored.cookies.length} cookies / ${restored.pages.length} tabs (BROWSE_PERSIST_STATE=1)`);
|
||
} else {
|
||
console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)');
|
||
}
|
||
})
|
||
.catch((err: any) => {
|
||
console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`);
|
||
});
|
||
let persistWarned = false;
|
||
// In-flight guard: never start a new snapshot while the previous one is
|
||
// still pending (a slow page.evaluate would otherwise pile up ticks).
|
||
let persistInFlight = false;
|
||
sessionPersistInterval = setInterval(() => {
|
||
// Shutdown gate (belt; shutdown()'s clearInterval is the suspenders):
|
||
// a tick that fires during browser teardown snapshots a degraded state
|
||
// (zero tabs) over the good final snapshot.
|
||
if (isShuttingDown) return;
|
||
if (persistInFlight) return; // skip the tick
|
||
persistInFlight = true;
|
||
persistSessionState(browserManager, sessionStatePath)
|
||
.catch((err: any) => {
|
||
// Warn once — a full disk must not spam the log every 30s, and a
|
||
// snapshot failure must never kill the daemon (R3).
|
||
if (!persistWarned) {
|
||
persistWarned = true;
|
||
console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`);
|
||
}
|
||
})
|
||
.finally(() => { persistInFlight = false; });
|
||
}, sessionPersistIntervalMs());
|
||
(sessionPersistInterval as any)?.unref?.();
|
||
}
|
||
|
||
// Clean up stale state files (older than 7 days)
|
||
try {
|
||
const stateDir = path.join(config.stateDir, 'browse-states');
|
||
if (fs.existsSync(stateDir)) {
|
||
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
||
for (const file of fs.readdirSync(stateDir)) {
|
||
const filePath = path.join(stateDir, file);
|
||
const stat = fs.statSync(filePath);
|
||
if (Date.now() - stat.mtimeMs > SEVEN_DAYS) {
|
||
fs.unlinkSync(filePath);
|
||
console.log(`[browse] Deleted stale state file: ${file}`);
|
||
}
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
console.warn('[browse] Failed to clean stale state files:', err.message);
|
||
}
|
||
|
||
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
||
console.log(`[browse] State file: ${config.stateFile}`);
|
||
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
||
|
||
// ─── Tunnel startup (optional) ────────────────────────────────
|
||
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
|
||
// pattern: bind a dedicated tunnel listener on an ephemeral port and
|
||
// point ngrok.forward() at IT, not the local daemon port.
|
||
if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) {
|
||
console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on');
|
||
} else if (process.env.BROWSE_TUNNEL === '1') {
|
||
const authtoken = resolveNgrokAuthtoken();
|
||
if (!authtoken) {
|
||
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
|
||
} else {
|
||
// Shared startTunnel helper: binds the tunnel listener, opens ngrok,
|
||
// and on any failure tears down BOTH ngrok and the Bun listener so we
|
||
// don't leak an ngrok session if the error happened after
|
||
// ngrok.forward() resolved.
|
||
const started = await startTunnel({
|
||
fetchHandler: handle.fetchTunnel,
|
||
authtoken,
|
||
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
|
||
});
|
||
if (!started.ok) {
|
||
console.error(`[browse] Failed to start tunnel: ${started.error.message}`);
|
||
}
|
||
}
|
||
} else if (process.env.BROWSE_TUNNEL_LOCAL_ONLY === '1') {
|
||
// Test-only: bind the dual-listener tunnel surface on 127.0.0.1 with NO
|
||
// ngrok forwarding. Lets paid evals exercise the surface==='tunnel' gate
|
||
// without an ngrok authtoken or live network. Production tunneling still
|
||
// requires BROWSE_TUNNEL=1 + a valid authtoken above.
|
||
try {
|
||
const boundTunnel = Bun.serve({
|
||
port: 0,
|
||
hostname: '127.0.0.1',
|
||
fetch: handle.fetchTunnel,
|
||
});
|
||
tunnelServer = boundTunnel;
|
||
tunnelActive = true;
|
||
const tunnelPort = boundTunnel.port;
|
||
console.log(`[browse] Tunnel listener bound (local-only test mode) on 127.0.0.1:${tunnelPort}`);
|
||
const releaseStateLock = acquireAgentStateLock(config.stateDir);
|
||
try {
|
||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||
if (stateContent.pid !== process.pid || stateContent.instanceId !== SERVER_INSTANCE_ID) throw new Error('daemon state was replaced');
|
||
stateContent.tunnelLocalPort = tunnelPort;
|
||
const tmpState = tmpStatePath();
|
||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||
fs.renameSync(tmpState, config.stateFile);
|
||
} finally { releaseStateLock(); }
|
||
} catch (err: any) {
|
||
console.error(`[browse] BROWSE_TUNNEL_LOCAL_ONLY=1 listener bind failed: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Test-only. Resets the module-level shutdown latch so a second test case
|
||
* can exercise shutdown() in the same process. Mirrors __resetRegistry in
|
||
* token-registry.ts. shutdown() short-circuits when isShuttingDown is true
|
||
* (see line near the start of shutdown), so without this, tests that call
|
||
* shutdown() more than once silently no-op after the first call.
|
||
*
|
||
* DO NOT call from production code. Defeats the shutdown re-entry guard,
|
||
* which can race process.exit with cfgBrowserManager.close() and the pkill /
|
||
* safeUnlinkQuiet side effects. The `__` prefix is the convention; nothing
|
||
* enforces it. If you find yourself reaching for this outside a test file,
|
||
* the right fix is to make isShuttingDown factory-scoped instead.
|
||
*/
|
||
export function __resetShuttingDown(): void {
|
||
isShuttingDown = false;
|
||
}
|
||
|
||
// Auto-kickoff only when this module is the entry point. Embedders
|
||
// (gbrowser phoenix overlay) import { start, buildFetchHandler, ... }
|
||
// without triggering the listener-binding side effects.
|
||
if (import.meta.main) {
|
||
start().catch((err) => {
|
||
console.error(`[browse] Failed to start: ${err.message}`);
|
||
// Write error to disk for the CLI to read — on Windows, the CLI can't capture
|
||
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
||
try {
|
||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||
mkdirSecure(config.stateDir);
|
||
writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
||
} catch {
|
||
// stateDir may not exist — nothing more we can do
|
||
}
|
||
process.exit(1);
|
||
});
|
||
}
|