Files
gstack/browse/src/server.ts
T
Garry TanandClaude Fable 5 c118e2402e v1.64.1.0 v1.64.1.0: the code-smell fix wave — every pipeline guard now provably fires (net −24,943 lines) (#2572)
* 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>
2026-08-15 09:37:04 -07:00

3212 lines
144 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gstack browse server — persistent Chromium daemon
*
* Architecture:
* Bun.serve HTTP on localhost → routes commands to Playwright
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
*
* State:
* State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
* Log files: <project-root>/.gstack/browse-{console,network,dialog}.log
* Port: random 10000-60000 (or BROWSE_PORT env for debug override)
*/
import { BrowserManager } 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);
});
}