mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 06:27:14 +02:00
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/', but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked paths is always empty, so those two gates were structurally incapable of failing and 7 of 10 hosts had no gate at all. New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via git diff for tracked output, plus a porcelain check that fails on untracked generated strays (git diff can't see brand-new files). The gitignored-hosts byte-freshness limitation is documented in the workflow comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): exorcise the sidebar-agent ghost from the test suite browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the test suite kept testing it for 48 versions. Nothing noticed because the free suite runs in no CI job and Bun-era module-load errors were suppressed in the Windows shard runner via an exclusion pattern whose own comment documented the breakage ('broken on every platform since v1.14 ... exit 0'). - Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed at module load (unguarded readFileSync of the deleted file); per-assertion triage confirmed every SERVER_SRC pin targeted the deleted chat prompt builder (zero hits in today's server.ts) — nothing to port. - Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2 passing tests pinned only the blanket auth gate, covered by server-auth.test.ts + dual-listener.test.ts. - Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow. - sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead tests removed (incl. 10 vacuous passes asserting on empty indexOf slices); 2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback, arrow-hint window widened). 95 pass / 0 fail. - sidebar-tabs.test.ts: both failures were stale pins, not regressions — forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass. - touchfiles.ts: drop the three sidebar E2E entries from BOTH maps (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted file, so those tests were unreachable by any diff. - test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): run the free test suite in CI (it ran nowhere) The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI job on any Linux/macOS runner — only Windows curated shards, paid evals, and doc-freshness gates existed. That's how two module-load-crashing test files survived 48 versions. Same cached Dockerfile.ci image and container wiring as evals.yml (deps restore, build, Chromium verify). Includes a module-load-error guard: older Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job also fails on any nonzero 'N errors' count in the summary — future crash-class regressions can't hide from the exact job built to catch them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): validate touchfile dependency paths exist on disk New guard in touchfiles.test.ts: every non-glob dep path must exist, and every glob's anchor directory must exist. This is the axis the 181-key two-map sync discipline never covered — an entry can point at a long-deleted file and diff-based selection then silently never triggers those tests (the sidebar trio sat rotted for 48 versions). First run immediately caught a fourth rotted entry: 'spec authored quality' referenced test/fixtures/spec/** (directory does not exist) and selected for a judge test that exists nowhere in the repo. Removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every addition widens it. '/sidebar-chat' stayed in the set after the endpoint was deleted with the chat-queue path, meaning any future route matching that path would have been silently tunnel-exposed. The set is now exactly the pair ceremony (/connect) and the scoped command endpoint (/command), and the dual-listener closed-set pin enforces that. Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param) without updating the test — red on main since then, invisible because the free suite had no CI job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete chain's shadow dispatcher that skipped every security gate meta-commands.ts carried a 'CLI mode' fallback that re-implemented command routing without the server pipeline's gates: no scope check, no domain check, no tab ownership, no rate limit, no hidden-element stripping, no scoped-token enveloping — and it called handleReadCommand without a BrowserManager, which also skipped the JS-origin cookie-exfiltration assertion. It was unreachable in production (server.ts always passes executeCommand) and one boolean away from being live. chain now hard-errors without a server context. handleReadCommand's bm param is required and assertJsOriginAllowed runs unconditionally. The chain tests that exercised the deleted fallback now route through a server-shaped executeCommand adapter (real handlers + trust wrapping + {status,result} envelope), so their behavioral coverage — sequencing, trust markers, pipe format, aliases, error reporting — survives on the production-shaped path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension): delete the dead chat-queue client surface The sidebar-command handler in background.js POSTed to a server endpoint that no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead code including error handling for the permanent 404, plus its allowlist entry. No sender in the extension ever emitted the message type. chatEnabled leaves the /health contract (server hardcoded false, background.js re-derived it, nothing consumed it — the chat input element it guarded is gone from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete dead exports the ripped chat path left behind Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble (GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO production callers since the chat-path agent that invoked them was ripped. The only live ML path is scanPageContent (testsavant) inside the security sidecar subprocess. Deleted by import graph: - security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript, shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput, all DEBERTA_* consts + load state. Header now states the live truth (imported only by security-sidecar-entry.ts). downloadFile kept, name intact — it is an enumerated egress sink (HF model download). - security-bunnative.ts + test: a research skeleton self-described as 'NOT a production replacement', shipped into src/ with zero importers. - security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a paid live-model benchmark for a layer that could not fire. The security-classifier-tdz test's only case exercised checkTranscript — gone. - security.ts: layer-model header rewritten to the live architecture; StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires the impossible transcript==='ok' for 'protected' (old on-disk session state with a transcript key is tolerated on read, never re-emitted). - security-sidecar-entry.ts needed zero changes: it serializes getClassifierStatus() verbatim and no consumer read .transcript (verified in sidecar-client + server.ts). - BROWSER.md security section matches reality (ensemble knob gone, 112MB not 22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as the pure, tested combiner of record — comments now flag transcript/deberta votes as producer-less. Net: 26 pass in security.test.ts incl. a NEW regression test for stale- transcript disk tolerance; egress-receipt tripwire green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: scrub the sidebar-agent ghost from comments and CLAUDE.md 20+ comments across 10 files still described the deleted sidebar-agent.ts as a live process — including load-bearing architecture claims ('IMPORTED ONLY BY sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone blocks in server.ts enumerating deleted identifiers by name (a false grep surface: searching processAgentEvent hit server.ts and looked live). CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3 content filters + testsavant via the security sidecar subprocess; the L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa download are gone (deleted as dead code this wave) with an explicit do-not-re-document note; attempts.jsonl is correctly attributed to tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated. Comments that survive now describe what IS, not what WAS: the promotion gate in domain-skills.ts explains why classifier_score>0 is load-bearing given no L4 load-time scan exists; file-permissions.ts names real sensitive files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): delete the codex-helpers shadow module gen-skill-docs.ts imported externalSkillName (unaliased) from resolvers/codex-helpers.ts at line 21 and then re-declared the same function locally — the import was silently shadowed, and the imported copy was the STALE one (it lacked the frontmatterName param the local copy grew). Three more functions were byte-identical duplicates, imported only under _-prefixed aliases to keep the module 'referenced', and transformFrontmatter was a superseded hardcoded-Codex variant. Nothing else imported the module. Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every generator run for nothing — and an unused review-resolver trio). Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff regen); gen-skill-docs.test.ts 405/405 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read Both fields carried JSDoc asserting embedder behavior that did not exist: the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both resolveChromiumProfile() call sites pass no argument. Worse than absent — an embedder passing idleTimeoutMs: 5000 silently got 30 minutes. Wiring them honestly is impossible today: the idle timer, activity state, and shutdown target are module-global, so a per-factory value would lie for any process running more than one handler. Deleted instead, with a ServerConfig note pointing at the deferred singleton/route-table refactor where real support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the honest knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): wire appendSecureFile at the four real log-append sites file-permissions.ts carries a 24-line rationale for why POSIX mode bits are insufficient on Windows and implements appendSecureFile (0600 at create, Windows ACL on first write only) — but its single caller was the dead logAttempt, while the four REAL page-content log writers (console/network/ dialog logs in server.ts, the command audit log) used raw fs.appendFileSync with no mode. Page-content-derived logs now get owner-only permissions from birth on every platform. Verified before wiring: mode applies atomically at create via appendFileSync {mode}, and the ACL pass runs only on first write — no per-append subprocess cost on the hot console-log path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stealth): handoff() uses the shared profile resolution + lock cleanup The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile, silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff into a profile with a stale SingletonLock could hang where launchHeaded() would have recovered. This was the third live drift between the three Chromium launch paths; the first two are documented in comments as shipped stealth regressions. Minimal targeted fix — the full buildLaunchConfig() extraction stays in the deferred queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): resolver registry describes the template language again Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW, MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK, MAKE_PDF_SETUP. The last two of those families are invoked programmatically by preamble.ts (functions kept, registry entries dropped); the question-tuning trio and the review coverage-audit wrapper were documented by their own module as existing 'for unit testing' that no test performed — deleted, along with generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso renders the full table' comment was itself stale) and its test describe. Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/ unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested, used by zero of the 65 registry entries — the generator loop simplifies to a direct function call. CLAUDE.md's redact-doc line stops advertising the dead token. Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation 737 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing escaped newlines). The resolver now reads the config, so the boundary has one owner. (autoplan's template carries deliberately generic variants, enforced by gen-skill-docs.test.ts:1358 — untouched by design.) The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in three resolvers and could never change the result: resolvers/types.ts already sets binDir to $GSTACK_BIN for every usesEnvVars host including codex. Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs + host-config suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir judgePtyState spawned the bare string 'claude' three definitions below the resolveClaudeBinary() helper this same file exports — broken under hermetic PATHs where every other launch in the file resolves correctly. eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/ while EvalCollector writes it into the per-project eval dir (or GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty whenever slug detection succeeded, i.e. the normal case. The heartbeat and per-run progress logs stay global by design (session-runner.ts: 'heartbeat stays global'). The three eval-CLI docstrings stop claiming the legacy dir is the primary location. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures test/skill-e2e-ship-idempotency.test.ts's own header documented that the monolith's SDK-harness version tests a synthetic prompt while it exercises the real /ship skill — the author knew the old suite was superseded and left both running, two paid LLM runs for one behavior. The weaker copy is gone; its 'ship-idempotency' diff-selection key goes with it (the dedicated file is periodic-tier, which always runs under EVALS_ALL — the key had no remaining consumer). Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader orphan that had drifted 46KB from its live successor (test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative; parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three tests pin three OTHER baseline versions — consolidation is queued, deletion of the unreferenced two is free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): delete zero-caller scripts; make host-config-export's docstring honest - bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its hand-rolled host list was already stale (SLATE_HOST.md cites it as a problem). Note: the deprecated gstack-brain-consumer/reader pair the audit flagged was already deleted upstream in v1.63 with a stay-deleted tripwire. - scripts/task-emission-schema.ts (61 lines): a typed schema module nothing imported; the tasks-section comment now documents the JSONL fields inline. - scripts/host-config-export.ts claimed to be the 'shell bridge for the bash setup script' — setup never calls it (its hand-rolled host lists drifting is a known follow-up). Docstring now states what it IS: a standalone, test-pinned query CLI not yet wired into setup. Its validateValue + CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the header claimed but nothing enforced). - KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel Three copies of the surrogate sanitizer existed with two algorithms (sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified byte-identical across 11 edge cases before converging) plus two identical sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is now the single source of truth; the runs-INSIDE-JSON.stringify egress invariant is unchanged at every call site and its pin tests were adapted to the new import shape without losing intent. The ngrok tunnel-start sequence existed three times in server.ts — the /tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path'). One startTunnel() now owns the ephemeral loopback bind, the pre-send egress receipt, the state-file RMW via tmpStatePath(), and the ordered error-path cleanup; callers keep their distinct response surfaces. The BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state field) and deliberately stays separate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): one session-cookie registry implementation, two instances pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo the cookie name — mint/validate/parse/prune/TTL, the exact code a security fix would have to land in twice (and a third hand-rolled cookie parse in terminal-agent.ts had already diverged; unified next commit). createSessionCookieStore() owns the implementation; both modules become thin instantiations keeping every exported name, their distinct threat-model docstrings, and separate token spaces (an SSE-read cookie must never grant PTY access). pty-session-lease.ts deliberately stays out — different contract (sessionId/secret split, refresh, env TTL). The factory imports nothing from token-registry (cookie-picker-auth-isolation invariant, still pinned by sse-session-cookie.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): terminal-agent uses the shared PTY cookie parser The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the fourth copy of the session-cookie parse, and the one that had already diverged from the others. Parsing now goes through extractPtyCookie; validation deliberately stays against the agent's own in-process validTokens map (the server's registry lives in a different process). The ws-handler pin test now pins the shared-parser call instead of the raw cookie-name literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10, pathRewrites mechanically derivable from the host name for 7/10, the 11-entry toolRewrites map byte-identical between openclaw and gbrain, and every asset change a 10-file edit (cursor and slate had already fallen out of three other hand-maintained lists). defineHost() owns the defaults; each host file now declares only what makes it different (slate/cursor: 8 lines each). Shared constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES. Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT rewrites, hermes's tool vocabulary, claude's denylist+prefixable install, opencode's wider runtimeRoot. Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a runtime walk confirmed no function-valued or undefined-keyed fields, so the JSON diff is complete); gen:skill-docs --host all zero-diff; host-config + gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines. docs/ADDING_A_HOST.md teaches the factory pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/, and browse/src with three tmp-suffix conventions. One of them was a latent bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the deterministic-tmp collision race browse/src/server.ts documents having hit in production (its fix, pid+random, was trapped in a comment at one site). lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup, pid+random suffix, optional mode applied at tmp creation so the file never exists with looser permissions) + atomicWriteQuiet (shutdown paths only). Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness (captured via the read-only-dir failure path — Bun's fs exports are readonly, no monkeypatching), and no-stray-tmp cleanup. Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts (snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse sites follow separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted The header claimed 'single source of truth... the ONLY copy' with write-time injection REJECTION — while appendJsonl never screened anything, only 1 of ~10 JSONL stores imported it, and a bypass appender lived in the same directory. Now: the contract is explicit (screening is the CALLER's job via hasInjection/firstInjectionMatch; the enforcing callers are named), a option applies 0600 at create for sensitive stores, and the lib bypasses are adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod backstop for files created looser by pre-mode versions). browse/src keeps its own appenders by design (compiled-binary surface, own secure-append helper) and the header now says so. gstack-decision's batched archive append stays deliberate (single-write crash-window semantics appendJsonl's one-record contract can't express). New pins: 0600-at-create, and a test that documents appendJsonl does NOT self-screen — so nobody can re-document it as self-screening without making it true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic Seven sites, each audited for its existing throw-vs-swallow contract before migrating: writeSessionState + the four fire-and-forget tab/state writers use atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time port-file write use atomicWriteSync (they threw before — and writeAgentRecord previously leaked its tmp file on rename failure, which the helper cleans). All carry {mode: 0o600} plus restrictFilePermissions after successful writes, preserving the Windows ACL hardening that writeSecureFile provided (mode bits are POSIX-only). server.ts untouched: its three state writes route through tmpStatePath(), pinned by server-tmp-state-path.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hosts): delete five dead HostConfig fields metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts), install.prefixable (skill_prefix is implemented entirely in bin/gstack-config), staticFiles (docstring cited a SOUL.md that never existed anywhere), and adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead — with a test asserting the field was undefined). Kept: learningsMode (wired next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by resolvers/utility.ts). Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen across all 10 hosts; host-config + gen-skill-docs suites green. Note: this commit also carries chunk-23 edits to the shared hosts/claude.ts + define-host.ts + host-config.test.ts files (skipSkills collapse, stale line-number comment drops) — pathspec commits, concurrent prep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report, open-gstack-browser + its connect-chrome symlink) silently received the HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4. Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a missing declaration throws at generation time with the template path (the 5 templates without {{PREAMBLE}} never invoke the resolver). The stale hand-written tier-map comment (wrong in 3 of 4 rows) is gone. Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks 127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured the doubled-preamble bug). New tests: missing-tier throw carries the path; every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the shared test/gen-skill-docs.test.ts.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): learningsMode is read from host config, not a hardcoded host name resolvers/learnings.ts branched on ctx.host === 'codex' while every host declared learningsMode — the field was decorative, and the 7 hosts configured 'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently received the 'full' cross-project flow their runtimes can't execute (it depends on AskUserQuestion + gstack-config plumbing). Output now matches declaration: basic hosts get the project-scoped search block. Blast radius proof: all committed Claude SKILL.md files and the three golden fixtures are byte-identical; the behavior diff lands only in the gitignored external-host trees (hand-verified: .cursor review's learnings section swaps the cross-project AskUserQuestion block for the project-scoped search). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts - The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which silently reverted any hand edit to their tracked outputs on regen) move to openclaw/templates/*.md source files; output shasums byte-identical. - setup's --host allowlists gain cursor + slate — both fully registered hosts with generated output, but './setup --host cursor' exited 1 because two hand-rolled lists in setup had drifted from hosts/index.ts. - scripts/proactive-suggestions.json deleted: 31KB regenerated on every run, read by nobody (the catalog-trim design's reader was never built); its emitter and three determinism tests (which guaranteed a file nothing reads didn't churn) retired with stays-retired pins. - claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated output (directory name collides with the host id 'claude'), in no registry. Recoverable from git if ever wanted under a non-colliding name. - openclaw's frozen extraFields.version '0.15.2.0' stamp dropped; includeSkills: [] no-ops omitted (the generator treats [] as absent); llms.txt 55 -> 54 skills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): correct preamble tiers for the 8 silently-heaviest skills With tiers now explicit, set them RIGHT by analogy to the tiered population: scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1 (launchers and artifact generators, like browse and make-pdf); landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools, like health and canary); spec -> tier 3 (interactive planning, like the plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding prose it never needed; tier-2 shed 20 each. Verification per the review protocol: regen diff reviewed (pure section-removal), skill-validation + size-budget + catalog-budget + v0-dormancy suites green (822 tests), and live smoke of the tier-corrected skills confirms the preamble renders the intended sections at each tier. These skills have ~no eval coverage — stated honestly; the wave's gate-tier eval run is the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted into six different predicates — the drift that made 'eval:bg:all runs everything' silently false. test/helpers/e2e-gate.ts owns the semantics now: describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero side effects (the existing e2e-helpers module runs a ~30s claude ping at import under EVALS=1, so the gate lives in its own module; purity is pinned by tests that scan imports and comment-stripped source). The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER unset -> SKIP, the exact trap that made eval:bg:all a non-run. The tier-alignment tripwire gains a second regex for the helper shape (old shape still detected — stragglers can't hide), and the sharded paid runner's PRE-SPAWN tier classifier learns the helper shape too: without that, every gate-sharded run would have spawned all 28 periodic shards just to skip them, each paying the e2e-helpers import ping (~15 min of dead wall clock in the CI-blocking lane). Verified: gate runs exclude the 29 periodic files, periodic excludes the 8 gate files — identical to pre-migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): migrate the 36 tier-gated eval files to describeE2ETier Mechanical two-liner swap in 34 files (each keeping its declared tier — all 36 predicates verified against E2E_TIERS before migrating); the two files with compound gates (overlay-harness's EvalCollector feed, codex-e2e's CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep their distinct stderr-message gate shapes by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery Both files re-implemented the diff-selection machinery e2e-helpers already exported. The helper gained computeDiffSelection() (extracted, identical behavior) and a trailing optional selection param on the *IfSelected helpers (defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120 duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and test.concurrent semantics via testConcurrentIfSelected. Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER intersection its local copy lacked (affects only direct bun test invocations of that file — it matches no eval-script glob); its recordE2E gains the helper's three diagnostic fields; skill-llm-eval sharded solo now runs e2e-helpers' module-scope preflight it already ran in combined processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks The full-suite shakeout (budgeted by the plan) surfaced both immediately: 1. server-embedder-terminal-port.test.ts stubbed process.exit and restored the REAL exit in its finally — but shutdown() schedules async work that can call process.exit AFTER restoration, killing the entire bun process mid-suite with exit 0 and NO summary. This is the silent-truncation class the new free-suite CI job guards against, reproduced locally on the first full run. Exit now stays a logging no-op between tests (late async exits become visible stderr lines, not process death); the true exit returns in afterAll. 2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected skills — their baseline was measured at the silent tier-4 default. Added to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.64.0.0 — the code-smell fix wave 35 commits, one PR: guard repairs (free suite in CI per-file, all-host freshness gates, tunnel allowlist, diff-selection validation), the sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports, ghost comments), config honesty (defineHost factory, dead fields deleted, preamble tiers explicit, spec double-render fixed), and dedup with safety nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval tier-gate). Net -24,943 lines across 183 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests step runs under bash (container sh rejects pipefail) Maiden-voyage shakeout, exactly as budgeted: the CI container's default shell is dash, which errors on 'set -o pipefail' before the first test ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests curates 8 container-incompatible files with reasons Second maiden-voyage shakeout round: 376 of 384 files ran green in the container on the first completed pass. The 8 that can't run there yet are excluded the same way the Windows shards curate POSIX-bound files — each with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server management, extension-origin identity, the job's own TMPDIR override, and three pre-existing env failures that fail on dev machines too). Anything outside the list that fails still fails the job; trimming the list is tracked follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix' auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the repo itself. In any environment where the loop shares a working tree (the free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all 52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated suites downstream (hermetic-skills-seeding, host-config golden, skill-census, skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the documented suppression; relink behavior stays covered by relink.test.ts's mock install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux GNU xargs runs 'ls -t' once even on empty input, listing the cwd and producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins the BSD behavior on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom The two parallel v1.64 waves (code-smell fix wave + main's #2571) each added shared-preamble prose, pushing document-release / design-consultation / cso past their size ratios on the v1.57.7.0 anchor and four carved skeletons (plan-ceo-review, plan-eng-review, office-hours, design-consultation) 22-280 B over their absolute caps. New baseline is union-normalized (skeleton + sections/*.md, matching what the harness measures); caps get +~1 KB headroom each with per-cap rationale. The v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and capture-parity-baseline.ts now documents the union-normalization step so the next rebaseline doesn't re-trip on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire - Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out to it), file (skill-validation's binary check), poppler-utils (make-pdf e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the bun.sh installer ignores a BUN_VERSION env var, so the old form silently installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the 1.3.10 devs run locally); pass the version as the positional arg. - free-tests.yml: git identity + safe.directory for the git-exercising tests (container checkout is owned by a different uid than runner); post-loop tree-mutation tripwire that names a tracked-file-mutating test instead of letting downstream collateral confuse the report; skip the documented variants-retry-after timing flake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE) The backgrounded update subshell inherited the session hook's stdout/stderr pipes. Once the hook exits and the caller closes them, any child that writes — git pull's autostash notice, setup output — dies of SIGPIPE, logged as PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests container, and reachable by any production hook runner that closes stdio promptly). Redirect the fork to /dev/null; all observability already flows through the session-update log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-decision-bins — explicit branch context for the scope filter CI checks out a detached HEAD, where gitBranch() returns undefined on both the log and search sides, so an implicitly branch-scoped decision can never surface (filterByScope requires a matching non-empty ctx.branch). Pass the branch explicitly on both sides — the filter logic is what's under test, not git branch detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): ring-buffer lease interplay — same TTL window, not same millisecond Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they straddle a millisecond boundary the exact-equality assertion flakes (observed in CI: expiries of ...525 vs ...526). Assert the expiries are within a 50 ms window instead — the invariant under test is that leases share a TTL policy, not that they mint in the same clock tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3212 lines
144 KiB
TypeScript
3212 lines
144 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 } from './browser-manager';
|
||
import { handleReadCommand, hasOutArg } from './read-commands';
|
||
import { handleWriteCommand } from './write-commands';
|
||
import { handleMetaCommand } from './meta-commands';
|
||
import { handleCookiePickerRoute, 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 { getStatus as getSecurityStatus } from './security';
|
||
import { isSidecarAvailable, scanWithSidecar } from './security-sidecar-client';
|
||
import { writeSecureFile, mkdirSecure, appendSecureFile } from './file-permissions';
|
||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||
import {
|
||
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
|
||
checkRate, createToken, createSetupKey, exchangeSetupKey, revokeToken,
|
||
listTokens, recordCommand,
|
||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||
} from './token-registry';
|
||
import { validateTempPath } from './path-security';
|
||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||
import { createSseEndpoint } from './sse-helpers';
|
||
import { initAuditLog, writeAuditEntry } from './audit';
|
||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } 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 { readAgentRecord, killAgentByRecord, agentRecordPath, spawnTerminalAgent } from './terminal-agent-control';
|
||
import { isProcessAlive } from './error-handling';
|
||
import { sanitizeBody, stripLoneSurrogateEscapes, stripLoneSurrogates, sanitizeReplacer } 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 { shouldSpawnXvfb, pickFreeDisplay, spawnXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||
import { logTunnelDenial } from './tunnel-denial-log';
|
||
import {
|
||
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||
buildSseSetCookie, SSE_COOKIE_NAME,
|
||
} from './sse-session-cookie';
|
||
import {
|
||
mintPtySessionToken, buildPtySetCookie, revokePtySessionToken,
|
||
} from './pty-session-cookie';
|
||
import {
|
||
mintLease, validateLease, refreshLease, revokeLease,
|
||
} from './pty-session-lease';
|
||
import * as fs from 'fs';
|
||
import * as net from 'net';
|
||
import * as path from 'path';
|
||
import * as crypto from 'crypto';
|
||
|
||
// ─── 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
|
||
|
||
/** Which HTTP listener accepted this request. */
|
||
export type Surface = 'local' | 'tunnel';
|
||
|
||
/**
|
||
* 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/route-table 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.
|
||
*/
|
||
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';
|
||
|
||
/**
|
||
* Commands reachable via POST /command over the tunnel surface. A paired
|
||
* remote agent can drive the browser (goto, click, text, etc.) but cannot
|
||
* configure the daemon, bootstrap new sessions, import cookies, or reach
|
||
* extension-inspector state. This allowlist maps to the eng-review decision
|
||
* logged in the CEO plan for sec-wave v1.6.0.0.
|
||
*/
|
||
export const TUNNEL_COMMANDS = new Set<string>([
|
||
// Original 17
|
||
'goto', 'click', 'text', 'screenshot',
|
||
'html', 'links', 'forms', 'accessibility',
|
||
'attrs', 'media', 'data',
|
||
'scroll', 'press', 'type', 'select', 'wait', 'eval',
|
||
// Tab + navigation primitives operator docs and CLI hints already promised
|
||
'newtab', 'tabs', 'back', 'forward', 'reload',
|
||
// Read/inspect/write operators paired agents need to be useful
|
||
'snapshot', 'fill', 'url', 'closetab',
|
||
]);
|
||
|
||
/**
|
||
* Pure gate: returns true iff the command is reachable over the tunnel surface.
|
||
* Extracted from the inline /command handler so the gate logic is unit-testable
|
||
* without standing up an HTTP listener. Behavior is identical to the inline
|
||
* check; the function canonicalizes the command (so aliases hit the same set)
|
||
* and returns false for null/undefined input.
|
||
*
|
||
* `args` is consulted so an `--out` invocation (e.g. `eval --out <file>`) is
|
||
* NEVER tunnel-dispatchable: `--out` turns an otherwise-readable command into a
|
||
* local-disk WRITE, and the tunnel surface never grants disk-write capability to
|
||
* remote paired agents. Omitting `args` preserves the old command-only behavior.
|
||
*/
|
||
export function canDispatchOverTunnel(command: string | undefined | null, args?: string[]): boolean {
|
||
if (typeof command !== 'string' || command.length === 0) return false;
|
||
if (Array.isArray(args) && hasOutArg(args)) return false;
|
||
const cmd = canonicalizeCommand(command);
|
||
return TUNNEL_COMMANDS.has(cmd);
|
||
}
|
||
|
||
/**
|
||
* 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 stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||
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);
|
||
|
||
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(): string {
|
||
return `${config.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__ = {
|
||
idleCheckTick,
|
||
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';
|
||
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
|
||
let parentGone = false;
|
||
setInterval(() => {
|
||
try {
|
||
process.kill(BROWSE_PARENT_PID, 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 / 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? Stay alive. Claude Code's Bash tool kills
|
||
// the parent shell between invocations. The idle timeout (30 min)
|
||
// handles eventual cleanup.
|
||
if (hasActivePicker()) return;
|
||
const headed = activeBrowserManager.getConnectionMode() === 'headed';
|
||
if (headed || tunnelActive) {
|
||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||
activeShutdown?.();
|
||
} else if (!parentGone) {
|
||
parentGone = true;
|
||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
|
||
}
|
||
}
|
||
}, 15_000);
|
||
} 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)');
|
||
}
|
||
|
||
// ─── 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);
|
||
}
|
||
|
||
// ─── Inspector State (in-memory) ──────────────────────────────
|
||
let inspectorData: InspectorResult | null = null;
|
||
let inspectorTimestamp: number = 0;
|
||
|
||
// Inspector SSE subscribers
|
||
type InspectorSubscriber = (event: any) => void;
|
||
const inspectorSubscribers = new Set<InspectorSubscriber>();
|
||
|
||
/** Diagnostic accessor used by the $B memory snapshot. */
|
||
export function getInspectorSubscriberCount(): number {
|
||
return inspectorSubscribers.size;
|
||
}
|
||
|
||
function emitInspectorEvent(event: any): void {
|
||
for (const notify of inspectorSubscribers) {
|
||
queueMicrotask(() => {
|
||
try { notify(event); } catch (err: any) {
|
||
console.error('[browse] Inspector event subscriber threw:', err.message);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Server ────────────────────────────────────────────────────
|
||
const browserManager = new BrowserManager();
|
||
// 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;
|
||
|
||
type PortCheckResult =
|
||
| { available: true }
|
||
| { available: false; code?: string; message: string };
|
||
|
||
type FailedPortAttempt = {
|
||
port: number;
|
||
result: Extract<PortCheckResult, { available: false }>;
|
||
};
|
||
|
||
const RANDOM_PORT_MIN = 10000;
|
||
const RANDOM_PORT_MAX = 60000;
|
||
const RANDOM_PORT_RETRIES = 5;
|
||
|
||
function normalizePortError(err: unknown): Extract<PortCheckResult, { available: false }> {
|
||
const maybeNodeError = err as NodeJS.ErrnoException | undefined;
|
||
return {
|
||
available: false,
|
||
code: maybeNodeError?.code,
|
||
message: maybeNodeError?.message || String(err),
|
||
};
|
||
}
|
||
|
||
function isOccupiedPort(result: Extract<PortCheckResult, { available: false }>): boolean {
|
||
return result.code === 'EADDRINUSE';
|
||
}
|
||
|
||
function formatPortFailureDetail(attempt: FailedPortAttempt): string {
|
||
const { code, message } = attempt.result;
|
||
return code ? `${attempt.port} (${code}: ${message})` : `${attempt.port} (${message})`;
|
||
}
|
||
|
||
function formatExplicitPortUnavailableError(
|
||
port: number,
|
||
result: Extract<PortCheckResult, { available: false }>
|
||
): Error {
|
||
if (isOccupiedPort(result)) {
|
||
return new Error(`[browse] Port ${port} (from BROWSE_PORT env) is in use`);
|
||
}
|
||
|
||
const detail = result.code ? `${result.code}: ${result.message}` : result.message;
|
||
return new Error(
|
||
`[browse] Cannot bind BROWSE_PORT=${port} on 127.0.0.1 (${detail}). ` +
|
||
`This usually means localhost port binding is blocked by the current sandbox or OS permissions, ` +
|
||
`not that the port is occupied. Allow localhost binding, or run browse from an unrestricted terminal.`
|
||
);
|
||
}
|
||
|
||
function formatRandomPortUnavailableError(attempts: FailedPortAttempt[]): Error {
|
||
const blockingAttempts = attempts.filter((attempt) => !isOccupiedPort(attempt.result));
|
||
|
||
if (blockingAttempts.length > 0) {
|
||
const last = blockingAttempts[blockingAttempts.length - 1];
|
||
return new Error(
|
||
`[browse] Cannot bind localhost ports after ${attempts.length} attempts in range ` +
|
||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}. Last error: ${formatPortFailureDetail(last)}. ` +
|
||
`This usually means the current sandbox or OS permissions are blocking localhost port binding, ` +
|
||
`not that every sampled port is occupied. Allow localhost binding, set BROWSE_PORT to an approved ` +
|
||
`port, or run browse from an unrestricted terminal.`
|
||
);
|
||
}
|
||
|
||
return new Error(
|
||
`[browse] No available port after ${RANDOM_PORT_RETRIES} attempts in range ` +
|
||
`${RANDOM_PORT_MIN}-${RANDOM_PORT_MAX}; every sampled port was already in use`
|
||
);
|
||
}
|
||
|
||
// Test if a port is available by binding and immediately releasing.
|
||
// Uses net.createServer instead of Bun.serve to avoid a race condition
|
||
// in the Node.js polyfill where listen/close are async but the caller
|
||
// expects synchronous bind semantics. See: #486
|
||
function checkPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<PortCheckResult> {
|
||
return new Promise((resolve) => {
|
||
const srv = net.createServer();
|
||
let settled = false;
|
||
const finish = (result: PortCheckResult) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve(result);
|
||
};
|
||
|
||
srv.once('error', (err) => finish(normalizePortError(err)));
|
||
try {
|
||
srv.listen(port, hostname, () => {
|
||
srv.close(() => finish({ available: true }));
|
||
});
|
||
} catch (err) {
|
||
finish(normalizePortError(err));
|
||
}
|
||
});
|
||
}
|
||
|
||
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
||
return checkPortAvailable(port, hostname).then((result) => result.available);
|
||
}
|
||
|
||
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
||
async function findPort(): Promise<number> {
|
||
// Explicit port override (for debugging)
|
||
if (BROWSE_PORT) {
|
||
const result = await checkPortAvailable(BROWSE_PORT);
|
||
if (result.available) {
|
||
return BROWSE_PORT;
|
||
}
|
||
throw formatExplicitPortUnavailableError(BROWSE_PORT, result);
|
||
}
|
||
|
||
// Random port with retry
|
||
const attempts: FailedPortAttempt[] = [];
|
||
for (let attempt = 0; attempt < RANDOM_PORT_RETRIES; attempt++) {
|
||
const port = RANDOM_PORT_MIN + Math.floor(Math.random() * (RANDOM_PORT_MAX - RANDOM_PORT_MIN));
|
||
const result = await checkPortAvailable(port);
|
||
if (result.available) {
|
||
return port;
|
||
}
|
||
attempts.push({ port, result });
|
||
}
|
||
throw formatRandomPortUnavailableError(attempts);
|
||
}
|
||
|
||
/**
|
||
* 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 with --admin for eval/cookies/storage access.`,
|
||
}),
|
||
};
|
||
}
|
||
|
||
// `--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 dispatcher (line ~2158). 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) {
|
||
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||
process.on('SIGINT', () => 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', () => {
|
||
safeUnlinkQuiet(config.stateFile);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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.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).
|
||
cleanSingletonLocks(resolveChromiumProfile());
|
||
safeUnlinkQuiet(config.stateFile);
|
||
}
|
||
// 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;
|
||
|
||
if (ownsTerminalAgent) {
|
||
agentWatchdogInterval = setInterval(() => {
|
||
if (isShuttingDown) return;
|
||
if (agentRespawnGuardTripped) return;
|
||
const stateDir = path.dirname(cfg.config.stateFile);
|
||
const record = readAgentRecord(stateDir);
|
||
// If the record exists and the PID is alive, the agent is healthy
|
||
// (or at least still answering signal 0). Slow-but-alive agents
|
||
// intentionally fall through here — split-brain is worse than
|
||
// unresponsiveness, and slow recovery is handled by the user via
|
||
// restart.
|
||
if (record && isProcessAlive(record.pid)) return;
|
||
// Either no record (never spawned, or cleaned up after crash) or
|
||
// PID is dead. Try to respawn.
|
||
const now = Date.now();
|
||
while (respawnHistory.length && now - respawnHistory[0] > RESPAWN_GUARD_WINDOW_MS) {
|
||
respawnHistory.shift();
|
||
}
|
||
if (respawnHistory.length >= RESPAWN_GUARD_MAX) {
|
||
agentRespawnGuardTripped = true;
|
||
console.error(
|
||
`[browse] terminal-agent respawn guard tripped (${RESPAWN_GUARD_MAX} crashes in ${RESPAWN_GUARD_WINDOW_MS / 1000}s) — manual restart required`,
|
||
);
|
||
return;
|
||
}
|
||
respawnHistory.push(now);
|
||
try {
|
||
const pid = spawnTerminalAgent({
|
||
stateFile: cfg.config.stateFile,
|
||
serverPort: cfg.browsePort,
|
||
ownerPid: process.pid,
|
||
cwd: cfg.config.projectDir,
|
||
});
|
||
if (pid) {
|
||
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
|
||
} else {
|
||
console.warn('[browse] terminal-agent respawn skipped — script not found on disk');
|
||
}
|
||
} catch (err: any) {
|
||
console.warn('[browse] terminal-agent respawn failed:', err?.message || err);
|
||
}
|
||
}, AGENT_WATCHDOG_TICK_MS);
|
||
// Detach the watchdog timer from Node's event-loop ref count so a
|
||
// healthy idle process can still exit cleanly if everything else is
|
||
// also unref'd. Bun's setInterval returns a Timer with unref().
|
||
(agentWatchdogInterval as any)?.unref?.();
|
||
}
|
||
|
||
// Factory-scoped validateAuth. Closes over cfg.authToken so every internal
|
||
// auth check sees the same token the routes receive. Module-level
|
||
// validateAuth was deleted in v1.35.0.0.
|
||
function validateAuth(req: Request): boolean {
|
||
const header = req.headers.get('authorization');
|
||
return header === `Bearer ${authToken}`;
|
||
}
|
||
|
||
// 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;
|
||
|
||
console.log('[browse] Shutting down...');
|
||
if (ownsTerminalAgent) {
|
||
// Identity-based kill (v1.44+). Replaces the v1.43- `pkill -f
|
||
// terminal-agent\.ts` regex teardown which matched sibling gstack
|
||
// sessions on the same host. Only the PID recorded in
|
||
// `<stateDir>/terminal-agent-pid` by THIS daemon's agent is signaled.
|
||
try {
|
||
const stateDir = path.dirname(config.stateFile);
|
||
const record = readAgentRecord(stateDir);
|
||
if (record) killAgentByRecord(record, 'SIGTERM');
|
||
} catch (err: any) {
|
||
console.warn('[browse] Failed to kill terminal-agent:', err.message);
|
||
}
|
||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-port'));
|
||
safeUnlinkQuiet(path.join(path.dirname(config.stateFile), 'terminal-internal-token'));
|
||
safeUnlinkQuiet(agentRecordPath(path.dirname(config.stateFile)));
|
||
}
|
||
try { detachSession(); } catch (err: any) {
|
||
console.warn('[browse] Failed to detach CDP session:', err.message);
|
||
}
|
||
inspectorSubscribers.clear();
|
||
if (cfgBrowserManager.isWatching()) cfgBrowserManager.stopWatch();
|
||
clearInterval(flushInterval);
|
||
clearInterval(idleCheckInterval);
|
||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||
await flushBuffers();
|
||
|
||
await cfgBrowserManager.close();
|
||
|
||
cleanSingletonLocks(resolveChromiumProfile());
|
||
safeUnlinkQuiet(config.stateFile);
|
||
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;
|
||
|
||
// 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);
|
||
};
|
||
|
||
// Substitute cfgBrowserManager for module-level browserManager in the
|
||
// dispatcher body so all browser-state reads/writes go through the cfg
|
||
// instance. Other module-level references (handleCommand, getTokenInfo,
|
||
// isRootRequest, etc.) take the token as a parameter and are passed
|
||
// `authToken` (the cfg-derived value) explicitly.
|
||
const browserManager = cfgBrowserManager;
|
||
|
||
|
||
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;
|
||
}
|
||
|
||
// GET /connect — alive probe. Unauth on both surfaces. Used by /pair
|
||
// and /tunnel/start to detect dead ngrok tunnels via the tunnel URL,
|
||
// since /health is not tunnel-reachable under the dual-listener design.
|
||
//
|
||
// Shares the same rate limit as POST /connect — otherwise a tunnel
|
||
// caller can probe unlimited GETs and lock out nothing, which makes
|
||
// the endpoint a free daemon-enumeration surface.
|
||
if (url.pathname === '/connect' && req.method === 'GET') {
|
||
if (!checkConnectRateLimit()) {
|
||
return new Response(JSON.stringify({ error: 'Rate limited' }), {
|
||
status: 429, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return new Response(JSON.stringify({ alive: true }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
|
||
if (url.pathname.startsWith('/cookie-picker')) {
|
||
return handleCookiePickerRoute(url, req, browserManager, authToken);
|
||
}
|
||
|
||
// Welcome page — served when GStack Browser launches in headed mode
|
||
if (url.pathname === '/welcome') {
|
||
const welcomePath = (() => {
|
||
// Gate GSTACK_SLUG on a strict regex BEFORE interpolating it into
|
||
// the filesystem path. Without this, a slug like "../../etc/passwd"
|
||
// would resolve to ~/.gstack/projects/../../etc/passwd/... — path
|
||
// traversal. Not exploitable today (attacker needs local env-var
|
||
// access), but the gate is one regex and buys us defense-in-depth.
|
||
const rawSlug = process.env.GSTACK_SLUG || 'unknown';
|
||
const slug = /^[a-z0-9_-]+$/.test(rawSlug) ? rawSlug : 'unknown';
|
||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||
const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
|
||
if (fs.existsSync(projectWelcome)) return projectWelcome;
|
||
// Fallback: built-in welcome page from gstack install. Reject
|
||
// SKILL_ROOT values containing '..' for the same defense-in-depth
|
||
// reason as the GSTACK_SLUG regex above. Not exploitable today
|
||
// (env set at install time), but the gate is one check.
|
||
const rawSkillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
|
||
if (rawSkillRoot.includes('..')) return null;
|
||
const builtinWelcome = `${rawSkillRoot}/browse/src/welcome.html`;
|
||
if (fs.existsSync(builtinWelcome)) return builtinWelcome;
|
||
return null;
|
||
})();
|
||
if (welcomePath) {
|
||
try {
|
||
const html = require('fs').readFileSync(welcomePath, 'utf-8');
|
||
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
||
} catch (err: any) {
|
||
console.error('[browse] Failed to read welcome page:', welcomePath, err.message);
|
||
}
|
||
}
|
||
// No welcome page found — serve a simple fallback (avoid ERR_UNSAFE_REDIRECT on Windows)
|
||
return new Response(
|
||
`<!DOCTYPE html><html><head><title>GStack Browser</title>
|
||
<style>body{background:#111;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;}
|
||
.msg{text-align:center;opacity:.7;}.gold{color:#f5a623;font-size:2em;margin-bottom:12px;}</style></head>
|
||
<body><div class="msg"><div class="gold">◈</div><p>GStack Browser ready.</p><p style="font-size:.85em">Waiting for commands from Claude Code.</p></div></body></html>`,
|
||
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
|
||
);
|
||
}
|
||
|
||
// ─── POST /extension-token — pinned-origin token bootstrap ──────
|
||
//
|
||
// The ONLY endpoint that hands out AUTH_TOKEN. GET /health used to
|
||
// carry the token (headed mode + any chrome-extension:// Origin),
|
||
// which meant ANY extension — or any localhost caller in headed
|
||
// mode — could read the root token. Now the token is released only
|
||
// to the one extension identity we ship: the Origin header must be
|
||
// exactly `chrome-extension://<GSTACK_EXTENSION_ID>`, where the ID
|
||
// is pinned by the "key" field in extension/manifest.json (derive
|
||
// it with `bun browse/scripts/extension-id.ts`). Chrome sets Origin
|
||
// on cross-origin POSTs from extension contexts and web pages
|
||
// cannot forge a chrome-extension:// Origin.
|
||
//
|
||
// Local listener only: NEVER added to TUNNEL_PATHS, so the tunnel
|
||
// surface 404s it by default-deny.
|
||
if (url.pathname === '/extension-token' && req.method === 'POST') {
|
||
// 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.
|
||
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';
|
||
if (!originOk || !hostOk) {
|
||
// No detail in the body — don't teach a probing caller which
|
||
// check failed.
|
||
return new Response(JSON.stringify({ error: 'Forbidden' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return new Response(JSON.stringify({ token: authToken }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// Health check — no auth required, does NOT reset idle timer.
|
||
// NEVER carries a token in any mode: token bootstrap is
|
||
// POST /extension-token (pinned extension Origin) and shell auth
|
||
// is POST /pty-session. Liveness/status only.
|
||
if (url.pathname === '/health') {
|
||
const healthy = await browserManager.isHealthy();
|
||
return new Response(JSON.stringify({
|
||
status: healthy ? 'healthy' : 'unhealthy',
|
||
mode: browserManager.getConnectionMode(),
|
||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||
tabs: browserManager.getTabCount(),
|
||
// Security module status — drives the shield icon in the sidepanel.
|
||
// Returns {status: 'protected'|'degraded'|'inactive', layers: {...}}.
|
||
// Fed by the page-content side (testsavant sidecar, canary state).
|
||
security: getSecurityStatus(),
|
||
// Terminal-agent discovery. ONLY a port number — never a token.
|
||
// Tokens flow via the /pty-session HttpOnly cookie path. See
|
||
// `pty-session-cookie.ts` for the rationale (codex outside-voice
|
||
// finding #2: don't reuse this endpoint for shell auth).
|
||
terminalPort: readTerminalPort(),
|
||
}), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── /pty-session — mint sessionId + lease + attachToken ─────────
|
||
//
|
||
// v1.44+ four-tuple shape:
|
||
// { terminalPort, sessionId, attachToken, leaseExpiresAt }
|
||
//
|
||
// - sessionId : stable, non-secret. Safe to log. Identifies "this
|
||
// terminal" across re-attaches.
|
||
// - attachToken : short-lived (30 min wall, single attach in practice
|
||
// since the agent revokes on WS close). Bearer for
|
||
// the /ws upgrade.
|
||
// - leaseExpiresAt: client-visible deadline for the lease. Re-attach
|
||
// only works inside this window.
|
||
//
|
||
// The lease + attachToken are minted together so a successful
|
||
// /pty-session is one round trip. Re-attach mints a fresh attachToken
|
||
// for the SAME sessionId via /pty-session/reattach.
|
||
//
|
||
// NEVER added to TUNNEL_PATHS — the tunnel surface 404s any
|
||
// /pty-session attempt by default-deny.
|
||
if (url.pathname === '/pty-session' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const port = readTerminalPort();
|
||
if (!port) {
|
||
return new Response(JSON.stringify({
|
||
error: 'terminal-agent not ready',
|
||
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
const lease = mintLease();
|
||
const minted = mintPtySessionToken();
|
||
const granted = await grantPtyToken(minted.token, lease.sessionId);
|
||
if (!granted) {
|
||
revokePtySessionToken(minted.token);
|
||
revokeLease(lease.sessionId);
|
||
return new Response(JSON.stringify({
|
||
error: 'failed to grant terminal session',
|
||
}), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
return new Response(JSON.stringify({
|
||
terminalPort: port,
|
||
sessionId: lease.sessionId,
|
||
attachToken: minted.token,
|
||
leaseExpiresAt: lease.expiresAt,
|
||
// Legacy alias — extensions still on the v1.43 wire shape keep
|
||
// working. Drop after one minor release once dogfood confirms.
|
||
ptySessionToken: minted.token,
|
||
expiresAt: minted.expiresAt,
|
||
}), {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Set-Cookie': buildPtySetCookie(minted.token),
|
||
},
|
||
});
|
||
}
|
||
|
||
// ─── /pty-session/reattach — mint fresh attachToken for existing sessionId
|
||
//
|
||
// Used by Commit 3's re-attach loop on the client. Validates the
|
||
// lease (rejects unknown/expired sessionId with 410 Gone), mints a
|
||
// fresh short-lived attachToken bound to the same sessionId, and
|
||
// pushes it to the agent. The client opens a new WS with the new
|
||
// token; the agent matches the sessionId binding and re-attaches
|
||
// to the existing PtySession (kept alive for the 60s detach
|
||
// window — Commit 3 wires that side).
|
||
if (url.pathname === '/pty-session/reattach' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const port = readTerminalPort();
|
||
if (!port) {
|
||
return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), {
|
||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
let body: any;
|
||
try { body = await req.json(); } catch { body = null; }
|
||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
||
const v = sessionId ? validateLease(sessionId) : { ok: false };
|
||
if (!v.ok) {
|
||
// 410 Gone — session window has closed (lease expired or never
|
||
// existed). Client must fall back to /pty-session for a brand-new
|
||
// session.
|
||
return new Response(JSON.stringify({ error: 'lease expired or unknown' }), {
|
||
status: 410, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const minted = mintPtySessionToken();
|
||
const granted = await grantPtyToken(minted.token, sessionId!);
|
||
if (!granted) {
|
||
revokePtySessionToken(minted.token);
|
||
return new Response(JSON.stringify({ error: 'failed to grant attach token' }), {
|
||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return new Response(JSON.stringify({
|
||
terminalPort: port,
|
||
sessionId,
|
||
attachToken: minted.token,
|
||
leaseExpiresAt: v.ok ? v.expiresAt : 0,
|
||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
|
||
// ─── /pty-restart — one-transaction kill + fresh mint ────────────
|
||
//
|
||
// The Restart button. Synchronously disposes the caller's existing
|
||
// PtySession on the agent, revokes the old lease, mints a fresh
|
||
// sessionId + lease + attachToken, and returns the new 4-tuple in
|
||
// one response. Zero race window between kill and mint (codex T2
|
||
// + D8 of the eng review).
|
||
if (url.pathname === '/pty-restart' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const port = readTerminalPort();
|
||
if (!port) {
|
||
return new Response(JSON.stringify({ error: 'terminal-agent not ready' }), {
|
||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
let body: any;
|
||
try { body = await req.json(); } catch { body = null; }
|
||
const oldSessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
||
// Best-effort dispose. Missing/unknown sessionId is non-fatal —
|
||
// the client may be doing a "restart from scratch" with no prior
|
||
// session (e.g. ENDED state). The fresh mint always proceeds.
|
||
if (oldSessionId) {
|
||
await restartPtySession(oldSessionId);
|
||
revokeLease(oldSessionId);
|
||
}
|
||
const lease = mintLease();
|
||
const minted = mintPtySessionToken();
|
||
const granted = await grantPtyToken(minted.token, lease.sessionId);
|
||
if (!granted) {
|
||
revokePtySessionToken(minted.token);
|
||
revokeLease(lease.sessionId);
|
||
return new Response(JSON.stringify({ error: 'failed to grant terminal session' }), {
|
||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return new Response(JSON.stringify({
|
||
terminalPort: port,
|
||
sessionId: lease.sessionId,
|
||
attachToken: minted.token,
|
||
leaseExpiresAt: lease.expiresAt,
|
||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
|
||
// ─── /pty-dispose — explicit teardown (pagehide / browser quit) ──
|
||
//
|
||
// sendBeacon-compatible: accepts the auth token in the BODY so the
|
||
// extension's pagehide handler can fire it without setting headers
|
||
// (sendBeacon doesn't support custom headers). Codex T3 fix —
|
||
// without this, every browser quit + sidebar close leaves a zombie
|
||
// PTY alive for the 60s detach window (Commit 3).
|
||
if (url.pathname === '/pty-dispose' && req.method === 'POST') {
|
||
let body: any;
|
||
try { body = await req.json(); } catch { body = null; }
|
||
const authTokenFromBody = typeof body?.authToken === 'string' ? body.authToken : null;
|
||
// Accept either header bearer OR body authToken. Both must match
|
||
// the root auth token; otherwise reject.
|
||
const headerToken = extractToken(req);
|
||
const authedByHeader = headerToken !== null && headerToken === authToken;
|
||
const authedByBody = authTokenFromBody !== null && authTokenFromBody === authToken;
|
||
if (!authedByHeader && !authedByBody) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
||
if (sessionId) {
|
||
await restartPtySession(sessionId);
|
||
revokeLease(sessionId);
|
||
}
|
||
return new Response(JSON.stringify({ ok: true }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── /internal/lease-refresh — loopback from terminal-agent on keepalive
|
||
//
|
||
// T6 PTY-only idle reset (codex outside-voice fix): the headless
|
||
// daemon's idle timer must reset only on active PTY usage, not on
|
||
// every passive SSE consumer. Terminal-agent calls this endpoint
|
||
// (lazily, only when its cached lease is within 5 min of expiry)
|
||
// on its 25s keepalive cycle. Refreshing the lease here also bumps
|
||
// lastActivity so the daemon stays alive while a sidebar terminal
|
||
// is actively in use.
|
||
//
|
||
// INTERNAL endpoint — bound to the root authToken so an external
|
||
// caller can't refresh another user's lease. Body: {sessionId}.
|
||
if (url.pathname === '/internal/lease-refresh' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
let body: any;
|
||
try { body = await req.json(); } catch { body = null; }
|
||
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : null;
|
||
const r = sessionId ? refreshLease(sessionId) : { ok: false };
|
||
if (!r.ok) {
|
||
return new Response(JSON.stringify({ error: 'lease expired or unknown' }), {
|
||
status: 410, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
// T6: PTY activity resets the daemon idle timer.
|
||
resetIdleTimer();
|
||
return new Response(JSON.stringify({ ok: true, expiresAt: r.expiresAt }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── /pty-inject-scan — pre-inject prompt-injection scan for the
|
||
// extension's gstackInjectToTerminal callers. The extension routes
|
||
// every page-derived text through this endpoint BEFORE writing to
|
||
// the PTY (#1370). Local-only by intent: not added to the tunnel
|
||
// allowlist; root-token auth required. Sidecar absence degrades to
|
||
// L4 unavailable (extension shows WARN + user confirm per D7).
|
||
if (url.pathname === '/pty-inject-scan' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(
|
||
JSON.stringify({ error: 'Unauthorized' }, sanitizeReplacer),
|
||
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
||
);
|
||
}
|
||
// 64KB request cap. Defense against accidentally posting an
|
||
// entire page DOM into the PTY path.
|
||
const contentLength = Number(req.headers.get('content-length') || '0');
|
||
if (contentLength > 64 * 1024) {
|
||
return new Response(
|
||
JSON.stringify({ error: 'payload-too-large', limit: 65536 }, sanitizeReplacer),
|
||
{ status: 413, headers: { 'Content-Type': 'application/json' } },
|
||
);
|
||
}
|
||
let body: { text?: unknown; origin?: unknown } = {};
|
||
try {
|
||
body = (await req.json()) as { text?: unknown; origin?: unknown };
|
||
} catch {
|
||
return new Response(
|
||
JSON.stringify({ error: 'malformed-json' }, sanitizeReplacer),
|
||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||
);
|
||
}
|
||
const text = typeof body.text === 'string' ? body.text : '';
|
||
const origin = typeof body.origin === 'string' ? body.origin : 'unknown';
|
||
if (text.length === 0) {
|
||
return new Response(
|
||
JSON.stringify({ error: 'missing-text' }, sanitizeReplacer),
|
||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||
);
|
||
}
|
||
|
||
// L1-L3 honest accounting (codex review correction):
|
||
// - URL blocklist forced to BLOCK in PTY context (override
|
||
// BROWSE_CONTENT_FILTER default — page-derived text in the
|
||
// REPL is a higher-risk surface than ordinary tool output).
|
||
// - L4 ML classifier via the sidecar when available.
|
||
// - L1-L3 envelope/datamarking is INFORMATIONAL only; the
|
||
// verdict is driven by the URL blocklist + L4.
|
||
// See CLAUDE.md "Sidebar security stack" + plan §"L1-L3 honest
|
||
// accounting".
|
||
let verdict: 'PASS' | 'WARN' | 'BLOCK' = 'PASS';
|
||
const reasons: string[] = [];
|
||
|
||
// Quick URL-blocklist check (re-uses the security module's
|
||
// pure-string helpers — no @huggingface/transformers dep).
|
||
// Pattern: text containing a known bad-actor domain → BLOCK.
|
||
if (/(\bbit\.ly|\btinyurl\.com|\bdiscord\.gg)/i.test(text)) {
|
||
verdict = 'BLOCK';
|
||
reasons.push('url-blocklist');
|
||
}
|
||
|
||
// L4 sidecar scan if available.
|
||
const sidecarAvail = isSidecarAvailable();
|
||
let l4: { available: boolean; verdict?: unknown; error?: string } = {
|
||
available: sidecarAvail.available,
|
||
};
|
||
if (sidecarAvail.available && verdict !== 'BLOCK') {
|
||
try {
|
||
const { verdict: layerVerdict } = await scanWithSidecar(text, {
|
||
timeoutMs: 5000,
|
||
});
|
||
l4 = { available: true, verdict: layerVerdict };
|
||
// LayerSignal shape: { verdict: 'safe'|'suspicious'|'unsafe', ... }
|
||
const lv = (layerVerdict as { verdict?: string })?.verdict;
|
||
if (lv === 'unsafe') {
|
||
verdict = 'BLOCK';
|
||
reasons.push('l4-unsafe');
|
||
} else if (lv === 'suspicious') {
|
||
verdict = 'WARN';
|
||
reasons.push('l4-suspicious');
|
||
}
|
||
} catch (err) {
|
||
l4 = {
|
||
available: false,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
};
|
||
// L4 failure during scan: degrade to WARN per D7.
|
||
if (verdict === 'PASS') {
|
||
verdict = 'WARN';
|
||
reasons.push('l4-unavailable');
|
||
}
|
||
}
|
||
} else if (!sidecarAvail.available && verdict === 'PASS') {
|
||
verdict = 'WARN';
|
||
reasons.push(`l4-unavailable:${sidecarAvail.reason ?? 'unknown'}`);
|
||
}
|
||
|
||
// BLOCK decisions are surfaced in the response shape; the
|
||
// existing writeDecision audit log is tab-scoped (per-page) and
|
||
// doesn't fit the PTY surface. The extension logs the BLOCK
|
||
// event into its own activity feed on receipt, which keeps the
|
||
// audit signal observable without bolting a new attempts.jsonl
|
||
// onto the server.
|
||
|
||
return new Response(
|
||
JSON.stringify(
|
||
{ verdict, reasons, l4, datamark: '<untrusted-page-content>' },
|
||
sanitizeReplacer,
|
||
),
|
||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||
);
|
||
}
|
||
|
||
// ─── /connect — setup key exchange for /pair-agent ceremony ────
|
||
if (url.pathname === '/connect' && req.method === 'POST') {
|
||
if (!checkConnectRateLimit()) {
|
||
return new Response(JSON.stringify({
|
||
error: 'Too many connection attempts. Wait 1 minute.',
|
||
}), { status: 429, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
try {
|
||
const connectBody = await req.json() as { setup_key?: string };
|
||
if (!connectBody.setup_key) {
|
||
return new Response(JSON.stringify({ error: 'Missing setup_key' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const session = exchangeSetupKey(connectBody.setup_key);
|
||
if (!session) {
|
||
return new Response(JSON.stringify({
|
||
error: 'Invalid, expired, or already-used setup key',
|
||
}), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
console.log(`[browse] Remote agent connected: ${session.clientId} (scopes: ${session.scopes.join(',')})`);
|
||
return new Response(JSON.stringify({
|
||
token: session.token,
|
||
expires: session.expiresAt,
|
||
scopes: session.scopes,
|
||
agent: session.clientId,
|
||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||
} catch {
|
||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── /token — mint scoped tokens (root-only) ──────────────────
|
||
if (url.pathname === '/token' && req.method === 'POST') {
|
||
if (!isRootRequest(req)) {
|
||
return new Response(JSON.stringify({
|
||
error: 'Only the root token can mint sub-tokens',
|
||
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
try {
|
||
const tokenBody = await req.json() as any;
|
||
if (!tokenBody.clientId) {
|
||
return new Response(JSON.stringify({ error: 'Missing clientId' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const session = createToken({
|
||
clientId: tokenBody.clientId,
|
||
scopes: tokenBody.scopes,
|
||
domains: tokenBody.domains,
|
||
tabPolicy: tokenBody.tabPolicy,
|
||
rateLimit: tokenBody.rateLimit,
|
||
expiresSeconds: tokenBody.expiresSeconds,
|
||
});
|
||
return new Response(JSON.stringify({
|
||
token: session.token,
|
||
expires: session.expiresAt,
|
||
scopes: session.scopes,
|
||
agent: session.clientId,
|
||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||
} catch {
|
||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── /token/:clientId — revoke a scoped token (root-only) ─────
|
||
if (url.pathname.startsWith('/token/') && req.method === 'DELETE') {
|
||
if (!isRootRequest(req)) {
|
||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const clientId = url.pathname.slice('/token/'.length);
|
||
const revoked = revokeToken(clientId);
|
||
if (!revoked) {
|
||
return new Response(JSON.stringify({ error: `Agent "${clientId}" not found` }), {
|
||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
console.log(`[browse] Revoked token for: ${clientId}`);
|
||
return new Response(JSON.stringify({ revoked: clientId }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── /agents — list connected agents (root-only) ──────────────
|
||
if (url.pathname === '/agents' && req.method === 'GET') {
|
||
if (!isRootRequest(req)) {
|
||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const agents = listTokens().map(t => ({
|
||
clientId: t.clientId,
|
||
scopes: t.scopes,
|
||
domains: t.domains,
|
||
expiresAt: t.expiresAt,
|
||
commandCount: t.commandCount,
|
||
createdAt: t.createdAt,
|
||
}));
|
||
return new Response(JSON.stringify({ agents }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── /pair — create setup key for pair-agent ceremony (root-only) ───
|
||
if (url.pathname === '/pair' && req.method === 'POST') {
|
||
if (!isRootRequest(req)) {
|
||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
try {
|
||
const pairBody = await req.json() as any;
|
||
// Default: full access (read+write+admin+meta). The trust boundary is
|
||
// the pairing ceremony itself, not the scope. --control adds browser-wide
|
||
// destructive commands (stop, restart, disconnect). --restrict limits scope.
|
||
const scopes = pairBody.control || pairBody.admin
|
||
? ['read', 'write', 'admin', 'meta', 'control'] as const
|
||
: (pairBody.scopes || ['read', 'write', 'admin', 'meta']) as const;
|
||
const setupKey = createSetupKey({
|
||
clientId: pairBody.clientId,
|
||
scopes: [...scopes],
|
||
domains: pairBody.domains,
|
||
rateLimit: pairBody.rateLimit,
|
||
});
|
||
// Verify tunnel is actually alive before reporting it (ngrok may have died externally).
|
||
// Probe via GET /connect — under dual-listener /health is NOT on the tunnel allowlist,
|
||
// so the old probe would return 404 and always mark the tunnel as dead.
|
||
let verifiedTunnelUrl: string | null = null;
|
||
if (tunnelActive && tunnelUrl) {
|
||
try {
|
||
const probe = await fetch(`${tunnelUrl}/connect`, {
|
||
method: 'GET',
|
||
headers: { 'ngrok-skip-browser-warning': 'true' },
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
if (probe.ok) {
|
||
verifiedTunnelUrl = tunnelUrl;
|
||
} else {
|
||
console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
|
||
await closeTunnel();
|
||
}
|
||
} catch {
|
||
console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
|
||
await closeTunnel();
|
||
}
|
||
}
|
||
return new Response(JSON.stringify({
|
||
setup_key: setupKey.token,
|
||
expires_at: setupKey.expiresAt,
|
||
scopes: setupKey.scopes,
|
||
tunnel_url: verifiedTunnelUrl,
|
||
server_url: `http://127.0.0.1:${browsePort}`,
|
||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||
} catch {
|
||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── /tunnel/start — start ngrok tunnel on demand (root-only) ──
|
||
//
|
||
// Dual-listener model: binds a SECOND Bun.serve listener on an
|
||
// ephemeral 127.0.0.1 port dedicated to tunnel traffic, then points
|
||
// ngrok.forward() at THAT port. The existing local listener (which
|
||
// serves /extension-token, /cookie-picker, /inspector/*, welcome, etc.)
|
||
// is never exposed to ngrok.
|
||
//
|
||
// Hard fail if the tunnel listener bind fails — NEVER fall back to
|
||
// the local port, which would silently defeat the whole security
|
||
// property.
|
||
if (url.pathname === '/tunnel/start' && req.method === 'POST') {
|
||
if (!isRootRequest(req)) {
|
||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
if (tunnelActive && tunnelUrl && tunnelServer) {
|
||
// Verify tunnel is still alive before returning cached URL.
|
||
// Probe GET /connect (the only unauth-reachable path on the tunnel
|
||
// surface); /health is NOT tunnel-reachable under dual-listener.
|
||
try {
|
||
const probe = await fetch(`${tunnelUrl}/connect`, {
|
||
method: 'GET',
|
||
headers: { 'ngrok-skip-browser-warning': 'true' },
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
if (probe.ok) {
|
||
return new Response(JSON.stringify({ url: tunnelUrl, already_active: true }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
} catch {}
|
||
// Tunnel is dead — tear down cleanly before restarting
|
||
console.warn('[browse] Cached tunnel is dead, restarting...');
|
||
await closeTunnel();
|
||
}
|
||
|
||
// 1) Resolve ngrok authtoken from env / .gstack / native config
|
||
const authtoken = resolveNgrokAuthtoken();
|
||
if (!authtoken) {
|
||
return new Response(JSON.stringify({
|
||
error: 'No ngrok authtoken found',
|
||
hint: 'Run: ngrok config add-authtoken YOUR_TOKEN',
|
||
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
|
||
// 2) Bind the tunnel listener + open ngrok via the shared helper
|
||
// (see startTunnel — hard-fails the bind, cleans up both ngrok
|
||
// and the Bun listener on any post-bind failure).
|
||
const started = await startTunnel({
|
||
fetchHandler: makeFetchHandler('tunnel'),
|
||
authtoken,
|
||
consent: 'pair_agent=on',
|
||
});
|
||
if (!started.ok) {
|
||
return new Response(JSON.stringify({
|
||
error: started.stage === 'bind'
|
||
? `Failed to bind tunnel listener: ${started.error.message}`
|
||
: `Failed to open ngrok tunnel: ${started.error.message}`,
|
||
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
return new Response(JSON.stringify({ url: started.url }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── SSE session cookie mint (auth required) ──────────────────
|
||
//
|
||
// Issues a short-lived view-only token in an HttpOnly SameSite=Strict
|
||
// cookie so EventSource calls can authenticate without putting the
|
||
// root token in a URL. The returned cookie is valid ONLY on the SSE
|
||
// endpoints (/activity/stream, /inspector/events); it is not a
|
||
// scoped token and cannot be used against /command.
|
||
//
|
||
// The extension calls this once at bootstrap with the root Bearer
|
||
// header, then opens EventSource with `withCredentials: true` which
|
||
// sends the cookie back automatically.
|
||
if (url.pathname === '/sse-session' && req.method === 'POST') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const minted = mintSseSessionToken();
|
||
return new Response(JSON.stringify({
|
||
expiresAt: minted.expiresAt,
|
||
cookie: SSE_COOKIE_NAME,
|
||
}), {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Set-Cookie': buildSseSetCookie(minted.token),
|
||
},
|
||
});
|
||
}
|
||
|
||
// Refs endpoint — auth required, does NOT reset idle timer
|
||
if (url.pathname === '/refs') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const refs = browserManager.getRefMap();
|
||
return new Response(JSON.stringify({
|
||
refs,
|
||
url: browserManager.getCurrentUrl(),
|
||
mode: browserManager.getConnectionMode(),
|
||
}), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// Activity stream — SSE, auth required, does NOT reset idle timer
|
||
if (url.pathname === '/activity/stream') {
|
||
// Auth: Bearer header OR view-only SSE session cookie (EventSource
|
||
// can't send Authorization headers, so the extension fetches a cookie
|
||
// via POST /sse-session first, then opens EventSource with
|
||
// withCredentials: true). The ?token= query param is NO LONGER
|
||
// accepted — URLs leak to logs/referer/history. See N1 in the
|
||
// v1.6.0.0 security wave plan.
|
||
const cookieToken = extractSseCookie(req);
|
||
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
||
// Cleanup contract (abort + enqueue-fail + heartbeat-fail, all
|
||
// idempotent) lives in createSseEndpoint; sanitizeReplacer is
|
||
// applied to every JSON.stringify inside the helper, so
|
||
// page-content-derived fields (URLs, command args, errors)
|
||
// stay surrogate-safe per CLAUDE.md egress invariant.
|
||
return createSseEndpoint(req, {
|
||
initialReplay: (send) => {
|
||
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
|
||
if (gap) send('gap', { gapFrom, availableFrom });
|
||
for (const entry of entries) send('activity', entry);
|
||
},
|
||
subscribe,
|
||
liveEventName: 'activity',
|
||
});
|
||
}
|
||
|
||
// Activity history — REST, auth required, does NOT reset idle timer
|
||
if (url.pathname === '/activity/history') {
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||
const { entries, totalAdded } = getActivityHistory(limit);
|
||
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
|
||
// ─── Batch endpoint — N commands, 1 HTTP round-trip ─────────────
|
||
// Accepts both root AND scoped tokens (same as /command).
|
||
// Executes commands sequentially through the full security pipeline.
|
||
// Designed for remote agents where tunnel latency dominates.
|
||
if (url.pathname === '/batch' && req.method === 'POST') {
|
||
const tokenInfo = getTokenInfo(req);
|
||
if (!tokenInfo) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
resetIdleTimer();
|
||
const body = await req.json();
|
||
const { commands } = body;
|
||
|
||
if (!Array.isArray(commands) || commands.length === 0) {
|
||
return new Response(JSON.stringify({ error: '"commands" must be a non-empty array' }), {
|
||
status: 400,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
if (commands.length > 50) {
|
||
return new Response(JSON.stringify({ error: 'Max 50 commands per batch' }), {
|
||
status: 400,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
const startTime = Date.now();
|
||
emitActivity({
|
||
type: 'command_start',
|
||
command: 'batch',
|
||
args: [`${commands.length} commands`],
|
||
url: browserManager.getCurrentUrl(),
|
||
tabs: browserManager.getTabCount(),
|
||
mode: browserManager.getConnectionMode(),
|
||
clientId: tokenInfo?.clientId,
|
||
});
|
||
|
||
const results: Array<{ index: number; status: number; result: string; command: string; tabId?: number }> = [];
|
||
for (let i = 0; i < commands.length; i++) {
|
||
const cmd = commands[i];
|
||
if (!cmd || typeof cmd.command !== 'string') {
|
||
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Missing "command" field' }), command: '' });
|
||
continue;
|
||
}
|
||
// Reject nested batches
|
||
if (cmd.command === 'batch') {
|
||
results.push({ index: i, status: 400, result: JSON.stringify({ error: 'Nested batch commands are not allowed' }), command: 'batch' });
|
||
continue;
|
||
}
|
||
const cr = await handleCommandInternal(
|
||
{ command: cmd.command, args: cmd.args, tabId: cmd.tabId },
|
||
tokenInfo,
|
||
{ skipRateCheck: true, skipActivity: true },
|
||
);
|
||
// Sanitize lone surrogates per-result (#1440 — /batch bypasses the
|
||
// handleCommand chokepoint, so it needs its own sanitization).
|
||
const safeResult = typeof cr.result === 'string' ? sanitizeBody(cr.result, !!cr.json) : cr.result;
|
||
results.push({
|
||
index: i,
|
||
status: cr.status,
|
||
result: safeResult,
|
||
command: cmd.command,
|
||
tabId: cmd.tabId,
|
||
});
|
||
}
|
||
|
||
const duration = Date.now() - startTime;
|
||
emitActivity({
|
||
type: 'command_end',
|
||
command: 'batch',
|
||
args: [`${commands.length} commands`],
|
||
url: browserManager.getCurrentUrl(),
|
||
duration,
|
||
status: 'ok',
|
||
result: `${results.filter(r => r.status === 200).length}/${commands.length} succeeded`,
|
||
tabs: browserManager.getTabCount(),
|
||
mode: browserManager.getConnectionMode(),
|
||
clientId: tokenInfo?.clientId,
|
||
});
|
||
|
||
// Sanitize the JSON envelope a second time (defense in depth) — catches
|
||
// any \uXXXX escape sequences for lone surrogates that survived the
|
||
// per-result pass.
|
||
const batchBody = stripLoneSurrogateEscapes(JSON.stringify({
|
||
results,
|
||
duration,
|
||
total: commands.length,
|
||
succeeded: results.filter(r => r.status === 200).length,
|
||
failed: results.filter(r => r.status !== 200).length,
|
||
}));
|
||
return new Response(batchBody, {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── File serving endpoint (for remote agents to retrieve downloaded files) ────
|
||
if (url.pathname === '/file' && req.method === 'GET') {
|
||
const tokenInfo = getTokenInfo(req);
|
||
if (!tokenInfo) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const filePath = url.searchParams.get('path');
|
||
if (!filePath) {
|
||
return new Response(JSON.stringify({ error: 'Missing "path" query parameter' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
try {
|
||
validateTempPath(filePath);
|
||
} catch (err: any) {
|
||
return new Response(JSON.stringify({ error: err.message }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
if (!fs.existsSync(filePath)) {
|
||
return new Response(JSON.stringify({ error: 'File not found' }), {
|
||
status: 404, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const stat = fs.statSync(filePath);
|
||
if (stat.size > 200 * 1024 * 1024) {
|
||
return new Response(JSON.stringify({ error: 'File too large (max 200MB)' }), {
|
||
status: 413, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
const MIME_MAP: Record<string, string> = {
|
||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||
'.avif': 'image/avif',
|
||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
||
'.pdf': 'application/pdf', '.json': 'application/json',
|
||
'.html': 'text/html', '.txt': 'text/plain', '.mhtml': 'message/rfc822',
|
||
};
|
||
const contentType = MIME_MAP[ext] || 'application/octet-stream';
|
||
resetIdleTimer();
|
||
return new Response(Bun.file(filePath), {
|
||
headers: {
|
||
'Content-Type': contentType,
|
||
'Content-Length': String(stat.size),
|
||
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
||
'Cache-Control': 'no-cache',
|
||
},
|
||
});
|
||
}
|
||
|
||
// ─── Command endpoint (accepts both root AND scoped tokens) ────
|
||
// Must be checked BEFORE the blanket root-only auth gate below,
|
||
// because scoped tokens from /connect are valid for /command.
|
||
if (url.pathname === '/command' && req.method === 'POST') {
|
||
const tokenInfo = getTokenInfo(req);
|
||
if (!tokenInfo) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
resetIdleTimer();
|
||
const body = await req.json() as any;
|
||
// Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
|
||
// Paired remote agents drive the browser but cannot configure the
|
||
// daemon, launch new browsers, import cookies, or rotate tokens.
|
||
if (surface === 'tunnel') {
|
||
if (!canDispatchOverTunnel(body?.command, body?.args)) {
|
||
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
|
||
return new Response(JSON.stringify({
|
||
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
|
||
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}. Note: --out (disk write) is never allowed over the tunnel.`,
|
||
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
}
|
||
return handleCommand(body, tokenInfo);
|
||
}
|
||
|
||
// ─── Auth-required endpoints (root token only) ─────────────────
|
||
|
||
if (!validateAuth(req)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// ─── Inspector endpoints ──────────────────────────────────────
|
||
|
||
// POST /inspector/pick — receive element pick from extension, run CDP inspection
|
||
if (url.pathname === '/inspector/pick' && req.method === 'POST') {
|
||
const body = await req.json();
|
||
const { selector, activeTabUrl } = body;
|
||
if (!selector) {
|
||
return new Response(JSON.stringify({ error: 'Missing selector' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
try {
|
||
const page = browserManager.getPage();
|
||
const result = await inspectElement(page, selector);
|
||
inspectorData = result;
|
||
inspectorTimestamp = Date.now();
|
||
// Also store on browserManager for CLI access
|
||
(browserManager as any)._inspectorData = result;
|
||
(browserManager as any)._inspectorTimestamp = inspectorTimestamp;
|
||
emitInspectorEvent({ type: 'pick', selector, timestamp: inspectorTimestamp });
|
||
return new Response(JSON.stringify(result), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
} catch (err: any) {
|
||
return new Response(JSON.stringify({ error: err.message }), {
|
||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// GET /inspector — return latest inspector data
|
||
if (url.pathname === '/inspector' && req.method === 'GET') {
|
||
if (!inspectorData) {
|
||
return new Response(JSON.stringify({ data: null }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const stale = inspectorTimestamp > 0 && (Date.now() - inspectorTimestamp > 60000);
|
||
return new Response(JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp, stale }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// POST /inspector/apply — apply a CSS modification
|
||
if (url.pathname === '/inspector/apply' && req.method === 'POST') {
|
||
const body = await req.json();
|
||
const { selector, property, value } = body;
|
||
if (!selector || !property || value === undefined) {
|
||
return new Response(JSON.stringify({ error: 'Missing selector, property, or value' }), {
|
||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
try {
|
||
const page = browserManager.getPage();
|
||
const mod = await modifyStyle(page, selector, property, value);
|
||
emitInspectorEvent({ type: 'apply', modification: mod, timestamp: Date.now() });
|
||
return new Response(JSON.stringify(mod), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
} catch (err: any) {
|
||
return new Response(JSON.stringify({ error: err.message }), {
|
||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// POST /inspector/reset — clear all modifications
|
||
if (url.pathname === '/inspector/reset' && req.method === 'POST') {
|
||
try {
|
||
const page = browserManager.getPage();
|
||
await resetModifications(page);
|
||
emitInspectorEvent({ type: 'reset', timestamp: Date.now() });
|
||
return new Response(JSON.stringify({ ok: true }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
} catch (err: any) {
|
||
return new Response(JSON.stringify({ error: err.message }), {
|
||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
}
|
||
|
||
// GET /inspector/history — return modification list
|
||
if (url.pathname === '/inspector/history' && req.method === 'GET') {
|
||
return new Response(JSON.stringify({ history: getModificationHistory() }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// GET /memory — diagnostic snapshot (auth required, does NOT reset idle).
|
||
// Same auth model as /activity/stream and /inspector/events: Bearer header
|
||
// OR view-only SSE-session cookie. Does NOT extend /health (which is
|
||
// unauthenticated liveness-only — token bootstrap moved to the pinned
|
||
// POST /extension-token); a separate endpoint with the standard SSE auth
|
||
// keeps /health free of anything worth stealing.
|
||
if (url.pathname === '/memory' && req.method === 'GET') {
|
||
const cookieToken = extractSseCookie(req);
|
||
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
const { buildMemorySnapshotJson } = await import('./memory-command');
|
||
const snapshot = await buildMemorySnapshotJson(cfgBrowserManager);
|
||
// sanitizeReplacer is required at every SSE/JSON egress that ships
|
||
// page-content-derived strings — tab.url and tab.title come from
|
||
// page content, so lone-surrogate bytes from broken emoji or
|
||
// mid-emoji splits could otherwise reach the sidebar / Claude API.
|
||
return new Response(JSON.stringify(snapshot, sanitizeReplacer), {
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
// GET /inspector/events — SSE for inspector state changes (auth required)
|
||
if (url.pathname === '/inspector/events' && req.method === 'GET') {
|
||
// Same auth model as /activity/stream: Bearer OR view-only cookie.
|
||
// ?token= query param dropped (see N1 in the v1.6.0.0 security plan).
|
||
const cookieToken = extractSseCookie(req);
|
||
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||
status: 401, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
// Cleanup contract (abort + enqueue-fail + heartbeat-fail,
|
||
// idempotent) lives in createSseEndpoint; sanitizeReplacer is
|
||
// applied to every JSON.stringify inside the helper. The
|
||
// inspector subscriber set stays here because it's also written
|
||
// to by emitInspectorEvent above.
|
||
return createSseEndpoint(req, {
|
||
initialReplay: inspectorData
|
||
? (send) => send('state', { data: inspectorData, timestamp: inspectorTimestamp })
|
||
: undefined,
|
||
subscribe: (notify) => {
|
||
inspectorSubscribers.add(notify);
|
||
return () => inspectorSubscribers.delete(notify);
|
||
},
|
||
liveEventName: 'inspector',
|
||
});
|
||
}
|
||
|
||
return new Response('Not found', { status: 404 });
|
||
};
|
||
|
||
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 */ });
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Xvfb auto-spawn (Linux + headed + no DISPLAY) ─────────────
|
||
// codex F2: walk display range to pick a free one (never hardcode :99);
|
||
// record start-time alongside PID so cleanup can validate ownership and
|
||
// not kill a recycled PID.
|
||
let xvfb: XvfbHandle | null = null;
|
||
const xvfbDecision = shouldSpawnXvfb(process.env, process.platform);
|
||
if (xvfbDecision.spawn) {
|
||
const displayNum = pickFreeDisplay();
|
||
if (displayNum == null) {
|
||
console.error('[browse] no free X display in range :99-:120 — refusing to clobber existing X servers');
|
||
process.exit(1);
|
||
}
|
||
try {
|
||
xvfb = await spawnXvfb(displayNum);
|
||
process.env.DISPLAY = xvfb.display;
|
||
console.log(`[browse] [xvfb] spawned on ${xvfb.display} (pid ${xvfb.pid})`);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error(`[browse] [xvfb] FAILED: ${msg}`);
|
||
console.error(`[browse] [xvfb] hint: ${xvfbInstallHint()}`);
|
||
process.exit(1);
|
||
}
|
||
process.on('exit', () => { try { xvfb?.close(); } catch { /* shutting down */ } });
|
||
} else if (process.env.BROWSE_HEADED === '1') {
|
||
console.log(`[browse] [xvfb] skipped: ${xvfbDecision.reason}`);
|
||
}
|
||
|
||
// 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
|
||
xvfb,
|
||
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,
|
||
});
|
||
|
||
// Write state file (atomic: write .tmp then rename)
|
||
const state: Record<string, unknown> = {
|
||
pid: process.pid,
|
||
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 } : {}),
|
||
};
|
||
const tmpFile = tmpStatePath();
|
||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||
fs.renameSync(tmpFile, config.stateFile);
|
||
|
||
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();
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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') {
|
||
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 (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 stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||
stateContent.tunnelLocalPort = tunnelPort;
|
||
const tmpState = tmpStatePath();
|
||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||
fs.renameSync(tmpState, config.stateFile);
|
||
} 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);
|
||
});
|
||
}
|