mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
* feat(loadouts): Opus 5.5 lite loadout with explicit model detection (GSTA-161)
modules/loadouts.json gains the lite loadout (eleven coaching preamble
pieces dropped; upgrade-check and brain-sync kept) and a models map that
sends only claude-opus-5-5 (and its dated snapshots) to it. Detection is
explicit: Capy passes --model to the installer, Claude Code opts in with
./setup --loadout lite; anything unmapped, invalid or unreadable serves full.
The checkout always stays the full render.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(loadouts): Opus 5.5 lite, detection order, Capy SKILL_ROOT, setup --loadout; troubleshooting anchors; changelog fragment (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(loadouts): claude_loadout read through CONFIG_BIN; capy skill context ceiling for the --model line (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (prune keeps a reused loadout folder, GSTACK_LOADOUT_SKILL_ROOT, claude_loadout guard test) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (Capy skill passes the model as GSTACK_MODEL so an older installer ignores it) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (prune only owned, unused folders; full root from the canonical Claude install; fallback reason and source) (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: loadout source example, install and resolve commands, Capy launch-file note (GSTA-161)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(autoplan): --ceo-only runs the CEO review alone with zero questions (GST-537)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: classify autoplan-ceo-only in the Codex host metadata
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: select autoplan-ceo-only on the skills it runs; say it needs Claude Code
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: run autoplan-ceo-only in the marathon lane
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: changelog entry for /autoplan --ceo-only
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: count the autoplan-ceo-only paid file in the shard inventory
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat: gstack-autoplan next --ceo-only runs the CEO review alone and ends reviewed (GST-537)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: budget and close recover a budget-stopped CEO-only run; reviewed is at rest for checkpoints
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: CEO-only finish leaves owner-only cap items undecided, refuses marker phase flags and a plan at the report path, and replaces ceo-review.md atomically
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: CEO_ONLY_CONFLICT covers a plan linked to the report path
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(browse): failing-first device and touch tests (GSTA-227 F3, #3058)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: the report-path guard keeps the lexical check, skips safe hard links and compares bigint file ids
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: per-session tabs for parallel agents; tab pins stop moving the shared active tab (#1961)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: keep the server.ts tab-pin anchor comment for source guards
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(browse): per-tab phone emulation and touch input ($B device, tap, swipe, pinch, touch; #3058, GSTA-227 F3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: copy lib/result-codes-autoplan.ts into the fixtures that copy the result-code tables, and keep it out of derived closures
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(qa, aside): touch pointer in /qa, Aside touch recipe verified on Aside 1.26, report Phones row (GSTA-227 F3, #3058)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: drop the sessions paragraph from /browse to stay inside the parity cap
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: F3 phone emulation and touch entry
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: hide the ps window in the session harness walk
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: the decisions.jsonl status list names auto_kept
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(browse): session cap, LRU eviction, headed release and scoped auto-session rows
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: sessions keep tab <id> picks, sidebar claude stays on the user's tab, pinned rebuild and state load rebind the scope
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (touch cancel with no finger held is a no-op)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: Step 11 review fixes (headed tab adoption, pinned viewport reset, CLI retry keeps --session, rebuild id remap, --json notice, evicted-session reset, device kept after annotated captures, render opts out of sessions, session: client ids reserved)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: headed viewport auto keeps the user's active tab; render's closetab opts out of sessions
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: sessions, phone emulation and touch in BROWSER.md; session: client ids reserved; TODOS #1961/#3058 shipped
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: copy lib/result-codes-browse.ts into the fixtures that copy the result-code modules
lib/result-codes.ts imports ./result-codes-browse, so fixtures with an explicit
lib list failed with 'Cannot find module'. pr-dependencies lists it with the other
reason-code modules as selection data. GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat: gstack-clean reclaims per-repo .gstack scratch and logs, dry run by default (#2627)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* browse: name the Aside touch recipe's coordinates <tap-x>/<tap-y> with a numeric grammar
The generated-bash lint (INV-3) rejected the free-text <x>/<y> placeholders. GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): leave hard-linked and unreadable logs alone; cut through a no-follow descriptor checked against the evaluated inode
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): tests that expect tmp removal see a quiet ps, so a codex elsewhere on the host cannot flake them
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): an unlistable tmp/ skips its group; liveness asked once per group on apply; BROWSER.md says which logs reset at daemon start
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): write output synchronously so --json reaches a pipe whole; test fixes for root, wrapper git and the ps stub
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(clean): every change rechecks the verified folder and the measured file; full writes; EPIPE ends output quietly
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): the early-close test checks the bin's own exit status
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: list gstack-clean among the standalone CLIs
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): copied-install fixtures and the dependency list carry lib/result-codes-clean.ts
The remote pregate failed 11 cases in gstack-doctor, plan-tune-cathedral and
timeline-stop-hook-slug: each copies an explicit list of lib/result-codes-*.ts
files into a temp install, and lib/result-codes.ts now imports
./result-codes-clean. Same lists GSTA-168 extended for result-codes-eval.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: regenerate gstack-doctor-components.sh for the gstack-clean result codes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* qa: fold the touch pointer into the Phases sentence to fit the parity size cap after the main merge
qa's union was 95988 bytes (ratio 1.142 > maxSizeRatio 1.142, train 75). Now 95975 (1.1419). GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.10.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): classify gstack-tasks rows files (tasks-*) as kept, after main added the producer
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.20.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (restore the lib/result-codes-plan.ts ratchet row the base merge dropped)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): the producer scan ends a name at a colon, so prose after a path names the file (train 78)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.30.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* outside-voice-api: caller receipts, stop_reason, OpenAI max_completion_tokens
A caller can write its own egress receipt (sink, consent, payload class);
the default stays the outside-voice receipt, so existing callers send and
record the same bytes. The result carries the provider's stop reason, and
an OpenAI request gets max_completion_tokens only when the caller sets an
output limit (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan gate: item classes and the owner's answer provenance
Gate items carry item_class, ruleKept() names the keep-direction pick, and
answered decision rows record owner_mode, owner_named, rec_hash and
answered_role parsed from the owner's own words (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan: shadow-mode decider module, frozen prompt and spend accounting
The switch (off by default, fixed at init), the lease, one model call per
gate with a page retry and a truncation retry under one deadline, the
once-per-revision record with its journaled sha256, the 10% audit mark,
and shadow-labeled ledger rows that count against the cap, show as
shadow= on GATE_SPEND and stay out of the per-voice price (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* gstack-autoplan: shadow every published gate, the shadow export, config and docs
Every driver command that prints a gate records the shadow first and
checkpoints after it; answer waits for a live lease and records a dead
one retrospectively; gstack-autoplan shadow exports answered revisions
for taste-replay R2 (SHADOW_BEFORE_ANSWER otherwise); gstack-config
autoplan_shadow / autoplan_shadow_model; gstack-artifact validate checks
the record's hash (GST-537 PR 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: shadow mode for the CEO-only decider (GST-537 PR 3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan shadow: /ship review fixes
- register test/fixtures/shadow-fake-model.ts in FREE_FIXTURES (free-fixtures inventory failed)
- withRunLock: count dead-owner reclaims apart from waitMs iterations, so a long wait can still reclaim
- buildPage: fill the decider template in one pass, so slot names inside the plan stay plan text
- revise regate: the shared publish step prints the GATE and ESCALATE lines once
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* autoplan shadow: reply files never follow a link; the retry reserves against the current cap
- a model reply is written by temp file and rename, so a link at the reply path is replaced, not written through
- each shadow reservation reads the run's current spend cap, so a budget change during the call is not undone
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* outside-voice-api: write the egress receipt under the caller's env
callModel resolved the receipt's state root from process.env even when the caller passed env, so the shadow
tests wrote to the real ~/.gstack (the free-suite home-containment guard caught it). The receipt now uses
req.env; the shadow tests give the fake model a private HOME, GSTACK_HOME and GSTACK_STATE_ROOT, and the receipt
test no longer sets process.env to pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): CEO-only runs leave main's open Critical/High finding items to the owner (no decision row)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(autoplan): shadow pages carry main's finding items; a finding item is never ceo_eligible
After merging #3767 (main 90a170c), open Critical/High findings are gate items
(item_class finding). The shadow records one row per gate item, so its pages now
include them. A CEO-only run leaves those items to the owner, so their rows are
ceo_eligible false (rule_pick was already null). The shadow fixtures follow
main's gate (items, counts, "Do it" tokens); the two merge adapters follow
main's doNext(out, rest, price) and its per-voice reservation (--estimate-usd).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: #3058 troubleshooting 'Fixed in' lines name v1.92.61.0 after the main merge
GSTA-227
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(investigate): drop the five-phase step procedure (GST-938)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: GST-938 fragment wording
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(investigate): keep the web-search sanitize guard; update BROWSER.md and docs/skills.md (review pass 1)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: GST-938 wording
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(investigate): pin the web-search guard; changelog size (adversarial review)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(clean): classify restamp-staging.json, the restamp producer main gained after #3779's base (wave browse)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (wave browse): scoped newtab keeps a concurrent tab switch; shadow validate catches a deleted record; a failed shadow publish is journaled
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: a failed shadow publish journals under the run lock and never throws (wave browse review pass 2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(unattended): shadow publish failures and the missing-record validate check (wave browse docs audit)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: wave browse integrator fixes fragment
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: shadow reader dedupes by run folder, so two runs started in the same second both count (wave browse CI free-suite 16)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* chore: regenerate doctor result anchors in generator order (wave browse rebuild without #3818)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: name the wave fragment after the rebuilt wave
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2569 lines
111 KiB
TypeScript
2569 lines
111 KiB
TypeScript
/**
|
||
* Browser lifecycle manager
|
||
*
|
||
* Chromium crash handling:
|
||
* browser.on('disconnected') → log error → process.exit(1)
|
||
* CLI detects dead server → auto-restarts on next command
|
||
* We do NOT try to self-heal — don't hide failure.
|
||
*
|
||
* Dialog handling:
|
||
* page.on('dialog') → auto-accept by default → store in dialog buffer
|
||
* Prevents browser lockup from alert/confirm/prompt
|
||
*
|
||
* Context recreation (useragent):
|
||
* recreateContext() saves cookies/storage/URLs, creates new context,
|
||
* restores state. Falls back to clean slate on any failure.
|
||
*/
|
||
|
||
import type { ChildProcess } from 'node:child_process';
|
||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||
import { writeSecureFile, mkdirSecure } from './file-permissions';
|
||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||
import { emitActivity } from './activity';
|
||
import { validateNavigationUrl, blockedNavigationReason } from './url-validation';
|
||
import { TabSession, type DeviceProfile, type RefEntry } from './tab-session';
|
||
import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config';
|
||
import { ensureProjectProfile } from './chromium-profiles';
|
||
import { launchWithXProtectHeal } from './xprotect-heal';
|
||
import { DIRECT_PROXY_ARG, forcesDirect } from './proxy-config';
|
||
import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb';
|
||
import { withCdpSession } from './cdp-bridge';
|
||
import { applyDevice } from './device-emulation';
|
||
import { resultLine } from '../../lib/result-codes';
|
||
import { sanitizeEcho } from '../../lib/sanitize-echo';
|
||
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
|
||
|
||
/**
|
||
* Headless GPU flags (#2709): on macOS 26 / Apple Silicon the headless-shell
|
||
* GPU process can peg ~800% CPU indefinitely after real page work, and
|
||
* --disable-gpu alone is not enough — the software-compositing GPU process
|
||
* still spawns and still spins. The reporter validated this exact flag set
|
||
* drops it to 0.0% with screenshots still working. darwin-gated: on Linux CI
|
||
* and Windows the GPU process behaves, and the flags are a mild automation
|
||
* tell. GSTACK_DISABLE_GPU=off opts out. Pure function (platform + env
|
||
* injected) so the darwin behavior is unit-testable on any host. Deliberately
|
||
* NOT in buildGStackLaunchArgs: that feeds the headed/GBrowser paths too,
|
||
* where GPU-off is user-visibly wrong.
|
||
*/
|
||
export function headlessGpuArgs(platform: string, env: NodeJS.ProcessEnv): string[] {
|
||
if (platform !== 'darwin') return [];
|
||
if ((env.GSTACK_DISABLE_GPU || '').toLowerCase() === 'off') return [];
|
||
return [
|
||
'--disable-gpu',
|
||
'--disable-software-rasterizer',
|
||
'--disable-gpu-compositing',
|
||
'--disable-gpu-watchdog',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
|
||
* already bakes the gstack extension in as a component extension (e.g.,
|
||
* GStack Browser.app / GBrowser). Passing --load-extension against such a
|
||
* binary triggers a ServiceWorkerState::SetWorkerId DCHECK because two
|
||
* copies of the same service worker try to register.
|
||
*
|
||
* Resolution:
|
||
* 1. GSTACK_CHROMIUM_KIND === 'custom-extension-baked' (preferred, explicit)
|
||
* 2. GSTACK_CHROMIUM_PATH path substring contains 'GBrowser' or 'gbrowser'
|
||
* (fallback for callers that only set the path)
|
||
*/
|
||
export function isCustomChromium(): boolean {
|
||
if (process.env.GSTACK_CHROMIUM_KIND === 'custom-extension-baked') return true;
|
||
const p = process.env.GSTACK_CHROMIUM_PATH || '';
|
||
return p.includes('GBrowser') || p.includes('gbrowser');
|
||
}
|
||
|
||
/**
|
||
* Decide whether Playwright should request Chromium's sandbox.
|
||
*
|
||
* Returns false on Windows (Bun→Node→Chromium chain breaks the sandbox,
|
||
* GitHub #276) and on Linux under root / CI / container (sandbox needs
|
||
* unprivileged user namespaces, which are missing for root and typically
|
||
* disabled in containers).
|
||
*
|
||
* When false, Playwright auto-adds --no-sandbox to the launch args — the
|
||
* desired behavior in those environments. When true, Playwright does NOT
|
||
* add --no-sandbox, which keeps Chromium's "unsupported command-line flag"
|
||
* yellow infobar from appearing on every headed launch.
|
||
*
|
||
* The headless launch path also pushes an explicit '--no-sandbox' into args
|
||
* when CI/CONTAINER/root is set; that push is now defensively redundant
|
||
* (Playwright will add it anyway when this returns false) and harmless.
|
||
*/
|
||
export function shouldEnableChromiumSandbox(): boolean {
|
||
if (process.platform === 'win32') return false;
|
||
// Explicit user override for Ubuntu/AppArmor and similar environments where
|
||
// unprivileged Chromium sandboxing is blocked even for normal users (the
|
||
// sandbox needs unprivileged user namespaces that the host policy denies,
|
||
// so /qa hangs without --no-sandbox). Setting GSTACK_CHROMIUM_NO_SANDBOX=1
|
||
// forces the sandbox off without changing the default for everyone else.
|
||
// See #1562.
|
||
if (process.env.GSTACK_CHROMIUM_NO_SANDBOX === '1') return false;
|
||
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||
return !(process.env.CI || process.env.CONTAINER || isRoot);
|
||
}
|
||
|
||
/**
|
||
* Thrown by probePoisonedChromiumBundle() when it finds — and removes — a
|
||
* Chromium bundle poisoned by the pre-v1.64 in-place rebrand (#2242).
|
||
* Call sites rethrow on `instanceof` (never message-string sniffing) so the
|
||
* actionable remediation reaches the user instead of being swallowed by the
|
||
* probe's fall-through-on-failure catch.
|
||
*/
|
||
export class PoisonedBundleError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'PoisonedBundleError';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Self-heal probe for bundles the OLD (pre-v1.64) rebrand code already
|
||
* poisoned (#2242): the mutation lives in the SHARED Playwright cache, so
|
||
* deleting the rebrand code fixes fresh installs only, and the documented
|
||
* deploy paths never run upgrade migrations. Detect the mutated plist and
|
||
* remove the bundle so the next `playwright install chromium` (or the
|
||
* upgrade migration) re-fetches a clean one.
|
||
*
|
||
* Removal scope: when the .app sits in the standard Playwright cache layout
|
||
* (chromium-<rev>/chrome-mac/<name>.app), the WHOLE chromium-<rev> revision
|
||
* dir is removed — Playwright's INSTALLATION_COMPLETE marker lives there,
|
||
* and `playwright install chromium` treats its presence as "is already
|
||
* downloaded", so removing only the .app would turn our own remediation
|
||
* command into a no-op that leaves the user with no browser at all. Outside
|
||
* that layout, the .app plus any sibling INSTALLATION_COMPLETE /
|
||
* DEPENDENCIES_VALIDATED markers are removed.
|
||
*
|
||
* Caller contract: pass ONLY Playwright-cache executables
|
||
* (chromium.executablePath()). A bundle supplied via GSTACK_CHROMIUM_PATH
|
||
* belongs to the wrapper/embedder — its plist legitimately says "GStack
|
||
* Browser" — and must never be deleted. Both call sites (launchHeaded and
|
||
* handoff) honor this, and as a second belt the probe refuses to act on the
|
||
* GSTACK_CHROMIUM_PATH executable itself.
|
||
*
|
||
* @param chromiumExecutablePath the Chromium binary inside the .app
|
||
* (…/<name>.app/Contents/MacOS/<name>), as returned by
|
||
* chromium.executablePath().
|
||
* @throws PoisonedBundleError after removing a poisoned bundle — the
|
||
* message carries the re-fetch command for the user.
|
||
*/
|
||
export function probePoisonedChromiumBundle(chromiumExecutablePath: string): void {
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// Belt to the caller contract: never act on the custom/embedder bundle.
|
||
const customPath = process.env.GSTACK_CHROMIUM_PATH;
|
||
if (customPath && path.resolve(chromiumExecutablePath) === path.resolve(customPath)) {
|
||
return;
|
||
}
|
||
|
||
const chromeContentsDir = path.resolve(path.dirname(chromiumExecutablePath), '..');
|
||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||
if (!fs.existsSync(chromePlist)) return;
|
||
if (!fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) return;
|
||
|
||
const appDir = path.resolve(chromeContentsDir, '..');
|
||
const revisionDir = path.resolve(appDir, '..', '..');
|
||
if (/^chromium-\d+$/.test(path.basename(revisionDir))) {
|
||
fs.rmSync(revisionDir, { recursive: true, force: true });
|
||
} else {
|
||
fs.rmSync(appDir, { recursive: true, force: true });
|
||
for (const marker of ['INSTALLATION_COMPLETE', 'DEPENDENCIES_VALIDATED']) {
|
||
fs.rmSync(path.join(path.dirname(appDir), marker), { force: true });
|
||
}
|
||
}
|
||
throw new PoisonedBundleError(
|
||
'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' +
|
||
'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' +
|
||
'Re-fetch a clean one with: bunx playwright install chromium — then retry.',
|
||
);
|
||
}
|
||
|
||
/** Playwright's public Browser type omits `process()`, which only browsers we launched provide. */
|
||
function launchedProcess(browser: Browser | null | undefined): ChildProcess | null {
|
||
const withProcess = browser as (Browser & { process?: () => ChildProcess | null }) | null | undefined;
|
||
return typeof withProcess?.process === 'function' ? withProcess.process() : null;
|
||
}
|
||
|
||
/**
|
||
* #2709: PID of the Chromium browser process gstack launched, read over CDP
|
||
* (`SystemInfo.getProcessInfo`, type "browser") because Playwright 1.62's
|
||
* Browser has no `process()`. Call it only for a launch gstack owns; a
|
||
* connected browser belongs to someone else and must never be reaped. Bounded:
|
||
* a hung or failed CDP call logs once and returns null, which means no reap.
|
||
*/
|
||
export async function launchedChromiumPid(browser: Browser, timeoutMs = 2_000): Promise<number | null> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const query = async (): Promise<number | null> => {
|
||
const session = await browser.newBrowserCDPSession();
|
||
try {
|
||
const { processInfo } = await session.send('SystemInfo.getProcessInfo');
|
||
const pid = processInfo.find((p) => p.type === 'browser')?.id;
|
||
return Number.isInteger(pid) && pid! > 0 ? pid! : null;
|
||
} finally {
|
||
await session.detach().catch(() => {});
|
||
}
|
||
};
|
||
try {
|
||
return await Promise.race([
|
||
query(),
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
||
}),
|
||
]);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.warn(`[browse] Could not record the Chromium PID (CDP SystemInfo.getProcessInfo: ${msg}); browse stop cannot reap a surviving Chromium.`);
|
||
return null;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve why the underlying Chromium ChildProcess is going away.
|
||
*
|
||
* The 'disconnected' Playwright event fires before the child process emits
|
||
* its own 'exit' in most cases, so .exitCode is null at that moment. Wait
|
||
* briefly (capped at 1s) for the exit then read .exitCode + .signalCode:
|
||
*
|
||
* exitCode === 0 && no signal → 'clean' (user Cmd+Q, normal shutdown)
|
||
* anything else → 'crash' (signal-kill, SIGSEGV, OOM, non-zero exit)
|
||
*
|
||
* Process supervisors (gbrowser's gbd HealthMonitor in cmd/gbd/health.go)
|
||
* read our exit code to decide whether to restart. The two callers in this
|
||
* file ride on top of this: a 'clean' result exits with code 0 (gbd skips
|
||
* restart, treats as user-intent); a 'crash' result keeps the existing
|
||
* per-path exit semantics (launch→1, launchHeaded→2, handoff→1) and gbd
|
||
* restarts on backoff.
|
||
*/
|
||
export async function resolveDisconnectCause(browser: Browser | null): Promise<'clean' | 'crash'> {
|
||
// `.process()` only exists on browsers we launched ourselves. A browser
|
||
// obtained via connectOverCDP() (or a stub in tests) has no such method —
|
||
// calling it blind throws inside the disconnect handler, which killed the
|
||
// whole daemon with "browser?.process is not a function".
|
||
const proc = launchedProcess(browser);
|
||
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
||
await new Promise<void>((resolve) => {
|
||
const timer = setTimeout(resolve, 1000);
|
||
proc.once('exit', () => {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
return proc?.exitCode === 0 && proc?.signalCode == null ? 'clean' : 'crash';
|
||
}
|
||
|
||
/**
|
||
* Exit-on-disconnect is DAEMON-ONLY semantics. The standalone server
|
||
* entrypoint opts in via markDaemonProcess() (under its import.meta.main
|
||
* gate, same contract as its signal handlers); embedders — gbrowser
|
||
* phoenix, and every test that launches a BrowserManager in-process —
|
||
* must never have a Chromium crash process.exit() their HOST. Observed
|
||
* live before this flag: a test-launched browser died mid-suite and the
|
||
* exit(1) killed the whole bun shard with no terminal summary (the
|
||
* truncation class the strict runner exists to catch).
|
||
*/
|
||
let daemonProcess = false;
|
||
export function markDaemonProcess(): void {
|
||
daemonProcess = true;
|
||
}
|
||
|
||
/**
|
||
* Headless `launch()` disconnect handler. In the standalone daemon: exits 0
|
||
* on clean user-quit, 1 on crash. Embedded contexts get the log line only.
|
||
* Inlined into the launch() body via a one-line dispatch so
|
||
* browser-manager's flow stays grep-friendly.
|
||
*/
|
||
export async function handleChromiumDisconnect(browser: Browser | null): Promise<void> {
|
||
const cause = await resolveDisconnectCause(browser);
|
||
if (!daemonProcess) {
|
||
console.error(`[browse] Chromium disconnected (${cause}) in an embedded context — host process continues.`);
|
||
return;
|
||
}
|
||
if (cause === 'clean') {
|
||
console.error('[browse] Chromium closed cleanly (user-initiated quit). Server exiting (0).');
|
||
process.exit(0);
|
||
}
|
||
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting (1).');
|
||
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
|
||
process.exit(1);
|
||
}
|
||
|
||
/**
|
||
* How long a tab-independent read waits on one tab's renderer (#3247). A page
|
||
* stuck in a long script never answers page.evaluate() or page.title(); before
|
||
* this bound, one such tab made /health call the whole daemon unhealthy and
|
||
* hung `tabs`. Kept well under the CLI's /health request timeout
|
||
* (HEALTH_REQUEST_TIMEOUT_MS in cli.ts) so the CLI reads the answer instead of
|
||
* timing out on it.
|
||
*/
|
||
export const TAB_PROBE_TIMEOUT_MS = 1_000;
|
||
|
||
/**
|
||
* Budget for saveState()'s per-tab storage read. Longer than the probe: a tab
|
||
* that misses it falls back to the last storage it answered with, which may
|
||
* be stale, so only a tab that is really stuck takes that path.
|
||
*/
|
||
const TAB_STORAGE_READ_TIMEOUT_MS = 5_000;
|
||
|
||
type TabStorage = { localStorage: Record<string, string>; sessionStorage: Record<string, string> };
|
||
type TabReadKind = 'probe' | 'title' | 'storage';
|
||
|
||
function urlOrigin(url: string): string {
|
||
return URL.canParse(url) ? new URL(url).origin : '';
|
||
}
|
||
|
||
/**
|
||
* Resolve with `work`'s value, or with undefined once `ms` passes. The losing
|
||
* read settles on its own when the renderer frees up, so callers pass a promise
|
||
* that cannot reject.
|
||
*/
|
||
function withinTabBudget<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
const deadline = new Promise<undefined>((resolve) => {
|
||
timer = setTimeout(() => resolve(undefined), ms);
|
||
(timer as { unref?: () => void }).unref?.();
|
||
});
|
||
return Promise.race([work, deadline]).finally(() => clearTimeout(timer));
|
||
}
|
||
|
||
export type { RefEntry };
|
||
|
||
// Re-export TabSession for consumers
|
||
export { TabSession };
|
||
|
||
export interface BrowserState {
|
||
cookies: Cookie[];
|
||
pages: Array<{
|
||
url: string;
|
||
isActive: boolean;
|
||
storage: TabStorage | null;
|
||
/**
|
||
* HTML content loaded via load-html (setContent), replayed after context recreation.
|
||
* In-memory only — never persisted to disk (HTML may contain secrets or customer data).
|
||
*/
|
||
loadedHtml?: string;
|
||
loadedHtmlWaitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||
/**
|
||
* Tab owner clientId for multi-agent isolation. Survives context recreation so
|
||
* scoped agents don't get locked out of their own tabs after viewport --scale.
|
||
* In-memory only.
|
||
*/
|
||
owner?: string;
|
||
/** `$B device` profile, re-applied before the restored tab's first request. */
|
||
device?: DeviceProfile;
|
||
/** The tab's id before the save, so scopes on it follow it to its restored id. In-memory only. */
|
||
tabId?: number;
|
||
}>;
|
||
}
|
||
|
||
/** The tab a scoped request runs on; owner set for session scopes (#1961). */
|
||
export interface TabScope { tabId: number; owner?: string; picked?: number }
|
||
|
||
export class BrowserManager {
|
||
private browser: Browser | null = null;
|
||
private context: BrowserContext | null = null;
|
||
// Proxy config applied to chromium.launch() when set (D8). Set by server.ts
|
||
// at startup based on BROWSE_PROXY_URL. For SOCKS5 with auth, server.ts
|
||
// points this at the local bridge (socks5://127.0.0.1:<bridgePort>); for
|
||
// HTTP/HTTPS or unauth SOCKS5, it's the upstream URL directly.
|
||
private proxyConfig: { server: string; bypass?: string; username?: string; password?: string } | null = null;
|
||
private pages: Map<number, Page> = new Map();
|
||
private tabSessions: Map<number, TabSession> = new Map();
|
||
private activeTabId: number = 0;
|
||
/**
|
||
* The tab one request runs on (#1961). A request that names a tab or a
|
||
* session runs inside this scope, so every getActiveSession() under it, after
|
||
* any await, resolves to that tab and never moves activeTabId for other
|
||
* callers. A scope with an owner follows that owner's tab across a context
|
||
* rebuild, which gives every tab a new id.
|
||
*/
|
||
private readonly tabScope = new AsyncLocalStorage<TabScope>();
|
||
private nextTabId: number = 1;
|
||
private extraHeaders: Record<string, string> = {};
|
||
private customUserAgent: string | null = null;
|
||
// #2709: identity of the Chromium child WE launched (headless path). The
|
||
// headless launch has no userDataDir, so killOrphanChromium's SingletonLock
|
||
// walk is a structural no-op for it — `browse stop` reported success while
|
||
// the orphaned GPU process kept spinning. PID alone is not identity (reuse);
|
||
// start time makes the later reap safe (same contract as xvfbPid/xvfbStartTime).
|
||
private chromiumProcInfo: { pid: number; startTime: string } | null = null;
|
||
|
||
/** PID + start-time of the launched Chromium child, when we own one. */
|
||
getChromiumProcInfo(): { pid: number; startTime: string } | null {
|
||
return this.chromiumProcInfo;
|
||
}
|
||
|
||
// ─── Viewport + deviceScaleFactor (context options) ──────────
|
||
// Tracked at the manager level so recreateContext() preserves them.
|
||
// deviceScaleFactor is a *context* option, not a page-level setter — changes
|
||
// require recreateContext(). Viewport width/height can change on-page, but we
|
||
// track the latest so context recreation restores it instead of hardcoding 1280x720.
|
||
private deviceScaleFactor: number = 1;
|
||
private currentViewport: { width: number; height: number } = { width: 1280, height: 720 };
|
||
|
||
/** Server port — set after server starts, used by cookie-import-browser command */
|
||
public serverPort: number = 0;
|
||
|
||
// ─── Tab Ownership (multi-agent isolation) ──────────────
|
||
// Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes.
|
||
private tabOwnership: Map<number, string> = new Map();
|
||
/** Old tab id → its id after a context rebuild, so every scope on it follows it. */
|
||
private restoredTabIds = new Map<number, number>();
|
||
/** Headless launch with BROWSE_EXTENSIONS_DIR: a persistent context (#2281). */
|
||
private extensionContext = false;
|
||
/** Pending and recorded navigation-guard verdicts per page (D2). */
|
||
private navigationGuards = new WeakMap<Page, { pending: Set<Promise<void>>; blocked: string | null }>();
|
||
/** One pending read per page and kind (#3247). A read that outlives its
|
||
* budget stays queued in the renderer until the page frees up, so a stuck
|
||
* tab reuses it instead of collecting a new one on every /health poll,
|
||
* `tabs` call or persist tick. */
|
||
private pendingTabReads = new WeakMap<Page, Map<TabReadKind, Promise<unknown>>>();
|
||
/** The last storage each page answered with, and the origin it came from. */
|
||
private lastTabStorage = new WeakMap<Page, { origin: string; storage: TabStorage }>();
|
||
// Our own 'disconnected' listeners per browser. Detach only these: removing
|
||
// every 'disconnected' listener also strips Playwright's internal one that
|
||
// resolves browser.close(), so each close waited the full race timeout (and
|
||
// Playwright 1.62 has no Browser.process() for the SIGKILL fallback).
|
||
private disconnectHandlers = new WeakMap<Browser, Array<() => void>>();
|
||
private onBrowserDisconnected(browser: Browser | null | undefined, handler: () => void): void {
|
||
if (!browser) return;
|
||
browser.on('disconnected', handler);
|
||
this.disconnectHandlers.set(browser, [...(this.disconnectHandlers.get(browser) ?? []), handler]);
|
||
}
|
||
private detachDisconnectHandlers(browser: Browser | null | undefined): void {
|
||
if (!browser) return;
|
||
for (const handler of this.disconnectHandlers.get(browser) ?? []) browser.off('disconnected', handler);
|
||
this.disconnectHandlers.delete(browser);
|
||
}
|
||
|
||
// ─── Dialog Handling (global, not per-tab) ──────────────────
|
||
private dialogAutoAccept: boolean = true;
|
||
private dialogPromptText: string | null = null;
|
||
|
||
// ─── Cookie Origin Tracking ────────────────────────────────
|
||
private cookieImportedDomains: Set<string> = new Set();
|
||
|
||
// ─── Handoff State ─────────────────────────────────────────
|
||
private isHeaded: boolean = false;
|
||
private consecutiveFailures: number = 0;
|
||
|
||
// ─── Watch Mode ─────────────────────────────────────────
|
||
private watching = false;
|
||
public watchInterval: ReturnType<typeof setInterval> | null = null;
|
||
private watchSnapshots: string[] = [];
|
||
private watchStartTime: number = 0;
|
||
|
||
// ─── Headed State ────────────────────────────────────────
|
||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||
|
||
/**
|
||
* Fired when a RUNNING daemon is promoted to headed mode (see handoff()),
|
||
* as opposed to starting headed. The server uses it to cancel the
|
||
* parent-process watchdog, which was registered on the assumption that mode
|
||
* is fixed at boot and would otherwise kill the freshly handed-off browser
|
||
* the next time the spawning shell exits.
|
||
*/
|
||
onHeadedPromotion?: () => void;
|
||
private intentionalDisconnect = false;
|
||
private xvfb: XvfbHandle | null = null;
|
||
private displayAllocation: Promise<void> | null = null;
|
||
private handoffPending: Promise<string> | null = null;
|
||
private activeTabRecovery: Promise<void> | null = null;
|
||
private closing = false;
|
||
private handoffPrevious: { browser: Browser; pages: Map<number, Page>; tabSessions: Map<number, TabSession>; activeTabId: number } | null = null;
|
||
|
||
getXvfbHandle(): XvfbHandle | null { return this.xvfb; }
|
||
|
||
async ensureHeadedDisplay(): Promise<void> {
|
||
if (this.closing) throw new Error('Browser is shutting down');
|
||
if (this.xvfb) return;
|
||
if (this.displayAllocation) return this.displayAllocation;
|
||
if (!shouldSpawnXvfb({ ...process.env, BROWSE_HEADED: '1' }, process.platform).spawn) return;
|
||
this.displayAllocation = (async () => {
|
||
try {
|
||
const handle = await spawnFreeXvfb();
|
||
if (this.closing) {
|
||
handle.close();
|
||
throw new Error('Browser is shutting down');
|
||
}
|
||
this.xvfb = handle;
|
||
} catch (err) {
|
||
throw new Error(`${err instanceof Error ? err.message : String(err)}. ${xvfbInstallHint()}`);
|
||
}
|
||
})();
|
||
try { await this.displayAllocation; }
|
||
finally { this.displayAllocation = null; }
|
||
}
|
||
|
||
closeOwnedDisplay(): void {
|
||
this.xvfb?.close();
|
||
this.xvfb = null;
|
||
}
|
||
|
||
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
||
// Idempotent threshold trackers: each guardrail fires exactly once per
|
||
// upward crossing of its threshold and re-arms when the tab count drops
|
||
// back below. Pre-guardrail, nothing tracked tab count growth and a
|
||
// user could accumulate hundreds of tabs (each holding 50–300 MB of
|
||
// Chromium-side RSS) without warning until the OS OOM-killer fired.
|
||
// The toast UX lives in the sidebar (extension/sidepanel.js); the
|
||
// server-side responsibility is the audit-trail activity entry that
|
||
// appears in the activity feed even when the sidebar is closed.
|
||
private static readonly TAB_GUARDRAIL_SOFT = 50;
|
||
private static readonly TAB_GUARDRAIL_HARD = 200;
|
||
private tabGuardrailSoftHit = false;
|
||
private tabGuardrailHardHit = false;
|
||
|
||
/**
|
||
* Called from context.on('page') after a new tab is tracked. Emits at
|
||
* most one activity entry per upward crossing of each threshold.
|
||
*/
|
||
private checkTabGuardrails(): void {
|
||
const total = this.pages.size;
|
||
if (!this.tabGuardrailSoftHit && total >= BrowserManager.TAB_GUARDRAIL_SOFT) {
|
||
this.tabGuardrailSoftHit = true;
|
||
const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_SOFT} (now ${total}). Consider closing unused tabs — each Chromium tab holds 50–300 MB.`;
|
||
console.warn(`[browse] ${msg}`);
|
||
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
||
}
|
||
if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) {
|
||
this.tabGuardrailHardHit = true;
|
||
const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Open the sidebar to see top RAM consumers.`;
|
||
console.error(`[browse] ${msg}`);
|
||
emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total });
|
||
}
|
||
}
|
||
|
||
/** Called from page.on('close') so the guardrails re-arm. */
|
||
private recheckTabGuardrailsOnClose(): void {
|
||
const total = this.pages.size;
|
||
if (this.tabGuardrailSoftHit && total < BrowserManager.TAB_GUARDRAIL_SOFT) {
|
||
this.tabGuardrailSoftHit = false;
|
||
}
|
||
if (this.tabGuardrailHardHit && total < BrowserManager.TAB_GUARDRAIL_HARD) {
|
||
this.tabGuardrailHardHit = false;
|
||
}
|
||
}
|
||
|
||
// Called when the headed browser disconnects without intentional teardown
|
||
// (user closed the window). Wired up by server.ts to run full cleanup
|
||
// (terminal agent, state file, profile locks) before exiting with code 2.
|
||
// Returns void or a Promise; rejections are caught and fall back to exit(2).
|
||
// `exitCode` is the resolved process exit code from the disconnect cause:
|
||
// 0 on clean user-initiated quit (e.g., Cmd+Q on headed Chromium), 2 on
|
||
// crash/signal-kill. Callers (server.ts) forward it to their shutdown
|
||
// pipeline so process supervisors (gbrowser's gbd) read the right signal.
|
||
public onDisconnect: ((exitCode?: number) => void | Promise<void>) | null = null;
|
||
|
||
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
||
|
||
// ─── Watch Mode Methods ─────────────────────────────────
|
||
isWatching(): boolean { return this.watching; }
|
||
|
||
startWatch(): void {
|
||
this.watching = true;
|
||
this.watchSnapshots = [];
|
||
this.watchStartTime = Date.now();
|
||
}
|
||
|
||
stopWatch(): { snapshots: string[]; duration: number } {
|
||
this.watching = false;
|
||
if (this.watchInterval) {
|
||
clearInterval(this.watchInterval);
|
||
this.watchInterval = null;
|
||
}
|
||
const snapshots = this.watchSnapshots;
|
||
const duration = Date.now() - this.watchStartTime;
|
||
this.watchSnapshots = [];
|
||
this.watchStartTime = 0;
|
||
return { snapshots, duration };
|
||
}
|
||
|
||
addWatchSnapshot(snapshot: string): void {
|
||
this.watchSnapshots.push(snapshot);
|
||
}
|
||
|
||
/**
|
||
* Find the gstack Chrome extension directory.
|
||
* Checks: repo root /extension, global install, dev install.
|
||
*/
|
||
private findExtensionPath(): string | null {
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const candidates = [
|
||
// Explicit override via env var (used by GStack Browser.app bundle)
|
||
process.env.BROWSE_EXTENSIONS_DIR || '',
|
||
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
|
||
path.resolve(__dirname, '..', '..', 'extension'),
|
||
// Global gstack install
|
||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'),
|
||
// Git repo root (detected via BROWSE_STATE_FILE location)
|
||
(() => {
|
||
const stateFile = process.env.BROWSE_STATE_FILE || '';
|
||
if (stateFile) {
|
||
const repoRoot = path.resolve(path.dirname(stateFile), '..');
|
||
return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension');
|
||
}
|
||
return '';
|
||
})(),
|
||
].filter(Boolean);
|
||
|
||
for (const candidate of candidates) {
|
||
try {
|
||
if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
|
||
return candidate;
|
||
}
|
||
} catch (err: any) {
|
||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Set the proxy config applied to chromium.launch() in launch() and
|
||
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
|
||
* bridge is up.
|
||
*/
|
||
setProxyConfig(cfg: { server: string; bypass?: string; username?: string; password?: string } | null): void {
|
||
this.proxyConfig = cfg;
|
||
}
|
||
|
||
/**
|
||
* Playwright passes a configured proxy explicitly as --proxy-server, so only
|
||
* a session browse resolved to direct was exposed: `BROWSE_PROXY_URL=direct`
|
||
* on a machine with HTTPS_PROXY set still routed every `goto` through that
|
||
* proxy (#3440 item 8). Direct now means DIRECT_PROXY_ARG on every launch
|
||
* path (headless, the extension context, headed, handoff) wherever Chromium
|
||
* would otherwise pick a proxy browse did not resolve (forcesDirect).
|
||
*/
|
||
private proxyLaunchArgs(): string[] {
|
||
return !this.proxyConfig && forcesDirect(process.env) ? [DIRECT_PROXY_ARG] : [];
|
||
}
|
||
|
||
/**
|
||
* Get the ref map for external consumers (e.g., /refs endpoint).
|
||
*/
|
||
getRefMap(): Array<{ ref: string; role: string; name: string }> {
|
||
try {
|
||
return this.getActiveSession().getRefEntries();
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async launch() {
|
||
this.closing = false;
|
||
// ─── Extension Support ────────────────────────────────────
|
||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||
// Extensions only run in a persistent context (launch() + newContext()
|
||
// isolates them), so that path uses launchPersistentContext with Chrome's
|
||
// new headless mode: no window, unlike the old off-screen window that
|
||
// macOS still showed (#2281, #432).
|
||
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs, STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs(), ...this.proxyLaunchArgs()];
|
||
// #2771/#1968: honor GSTACK_CHROMIUM_PATH headless too (NixOS, macOS 13).
|
||
// That bundle belongs to the user, so the XProtect heal never touches it,
|
||
// and a launch failure names it.
|
||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||
const nameCustomChromium = (err: unknown): never => {
|
||
if (!executablePath) throw err;
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
throw new Error(`Chromium at GSTACK_CHROMIUM_PATH=${executablePath} failed to launch: ${message}\nFix: point GSTACK_CHROMIUM_PATH at a working Chromium or Chrome binary, or unset it to use Playwright's bundled Chromium.`, { cause: err });
|
||
};
|
||
|
||
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
||
// are typically disabled in containers and are never available for the root
|
||
// user on Linux. Detect all three cases and add --no-sandbox automatically.
|
||
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||
if (process.env.CI || process.env.CONTAINER || isRoot) {
|
||
launchArgs.push('--no-sandbox');
|
||
}
|
||
|
||
if (extensionsDir) {
|
||
// Skip --load-extension when running against a custom Chromium build that
|
||
// already bakes the extension in (e.g., GBrowser / GStack Browser.app).
|
||
// Loading it twice causes a ServiceWorkerState::SetWorkerId DCHECK crash.
|
||
if (!isCustomChromium()) {
|
||
launchArgs.push(
|
||
`--disable-extensions-except=${extensionsDir}`,
|
||
`--load-extension=${extensionsDir}`,
|
||
);
|
||
}
|
||
launchArgs.push('--headless=new');
|
||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||
}
|
||
|
||
// #2709: headless-only GPU flags; headed/GBrowser sessions keep the GPU.
|
||
launchArgs.push(...headlessGpuArgs(process.platform, process.env));
|
||
|
||
const contextOptions: BrowserContextOptions = {
|
||
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
|
||
deviceScaleFactor: this.deviceScaleFactor,
|
||
};
|
||
if (this.customUserAgent) {
|
||
contextOptions.userAgent = this.customUserAgent;
|
||
}
|
||
|
||
if (extensionsDir) {
|
||
const profileDir = require('path').join(resolveConfig().stateDir, 'extensions', 'chromium-profile');
|
||
require('fs').mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||
cleanSingletonLocks(profileDir);
|
||
this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(profileDir, {
|
||
headless: false, // --headless=new in launchArgs: windowless, extensions run
|
||
handleSIGINT: false,
|
||
handleSIGTERM: false,
|
||
handleSIGHUP: false,
|
||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||
args: launchArgs,
|
||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||
...contextOptions,
|
||
...(executablePath ? { executablePath } : {}),
|
||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||
}), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium);
|
||
this.extensionContext = true;
|
||
this.browser = this.context.browser();
|
||
this.onBrowserDisconnected(this.browser, () => {
|
||
void handleChromiumDisconnect(this.browser);
|
||
});
|
||
for (const page of this.context.pages()) await page.close().catch(() => {});
|
||
if (Object.keys(this.extraHeaders).length > 0) {
|
||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||
}
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(this.context);
|
||
await this.newTab();
|
||
return;
|
||
}
|
||
|
||
// XProtect self-heal wrapper (P0 #2554): a macOS definition update can
|
||
// start SIGKILLing the pinned Chromium at spawn. On the classified
|
||
// signature, clear quarantine on the Playwright cache + force-reinstall
|
||
// once, then retry this launch once. A GSTACK_CHROMIUM_PATH bundle is
|
||
// scoped out of the heal.
|
||
this.browser = await launchWithXProtectHeal(() => chromium.launch({
|
||
headless: true,
|
||
// #2220: the daemon owns signal policy, not Playwright. Playwright's
|
||
// default handlers close Chromium the moment THIS process receives
|
||
// SIGINT/SIGTERM/SIGHUP — which fights the deliberate headless
|
||
// SIGTERM-ignore in server.ts (the daemon survives the signal but
|
||
// loses its browser out from under it). All three are false; server.ts
|
||
// routes the signals it actually honors through activeShutdown, which
|
||
// closes Chromium itself.
|
||
handleSIGINT: false,
|
||
handleSIGTERM: false,
|
||
handleSIGHUP: false,
|
||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
||
// on Linux root/CI/container, where the sandbox requires unprivileged user
|
||
// namespaces that aren't available.
|
||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||
...(executablePath ? { executablePath } : {}),
|
||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||
}), { usesCustomExecutable: Boolean(executablePath) }).catch(nameCustomChromium);
|
||
|
||
// Chromium disconnect → distinguish clean user-quit from crash. Both
|
||
// events look identical to Playwright (one 'disconnected' fires), but
|
||
// the underlying ChildProcess exit code separates them:
|
||
// exitCode === 0 → clean quit (user Cmd+Q on macOS, normal shutdown)
|
||
// exitCode !== 0 → crash, signal-kill, or OOM
|
||
// Process supervisors (gbrowser's gbd) consume our exit code: code 0
|
||
// means "user wanted this, don't restart"; non-zero means "crash, please
|
||
// bring me back." Without this distinction every Cmd+Q gets treated as
|
||
// a crash and the user-visible window keeps respawning.
|
||
this.onBrowserDisconnected(this.browser, () => {
|
||
void handleChromiumDisconnect(this.browser);
|
||
});
|
||
|
||
// #2709: record the child's identity so the CLI can reap a survivor after
|
||
// daemon shutdown. We launched this browser, so its PID is ours to record.
|
||
{
|
||
const pid = await launchedChromiumPid(this.browser);
|
||
this.chromiumProcInfo = pid ? { pid, startTime: readPidStartTime(pid) } : null;
|
||
}
|
||
|
||
this.context = await this.browser.newContext(contextOptions);
|
||
|
||
if (Object.keys(this.extraHeaders).length > 0) {
|
||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||
}
|
||
|
||
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
|
||
// restores window.chrome.* shape, aligns Notification.permission, sets
|
||
// per-install hardware, and strips automation globals + the Permissions
|
||
// notifications tell. We still do NOT fake navigator.plugins/languages —
|
||
// faking those to fixed values flags more bot-like, not less (D7).
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(this.context);
|
||
|
||
// Create first tab
|
||
await this.newTab();
|
||
}
|
||
|
||
// ─── Headed Mode ─────────────────────────────────────────────
|
||
/**
|
||
* Launch Playwright's bundled Chromium in headed mode with the gstack
|
||
* Chrome extension auto-loaded. Uses launchPersistentContext() which
|
||
* is required for extension loading (launch() + newContext() can't
|
||
* load extensions).
|
||
*
|
||
* The browser launches headed with a visible window — the user sees
|
||
* every action Claude takes in real time.
|
||
*/
|
||
async launchHeaded(authToken?: string): Promise<void> {
|
||
this.closing = false;
|
||
await this.ensureHeadedDisplay();
|
||
// Clear old state before repopulating
|
||
this.pages.clear();
|
||
this.tabSessions.clear();
|
||
this.nextTabId = 1;
|
||
|
||
// Find the gstack extension directory for auto-loading
|
||
const extensionPath = this.findExtensionPath();
|
||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||
const launchArgs = [
|
||
'--hide-crash-restore-bubble',
|
||
// Anti-bot-detection: --disable-blink-features=AutomationControlled (and any
|
||
// future blink-level tells) via the shared STEALTH_LAUNCH_ARGS constant — the
|
||
// same flag launch() and handoff() use, kept in one place instead of a literal.
|
||
...STEALTH_LAUNCH_ARGS,
|
||
// GStack Pack 1: per-install hardware/GPU/UA-CH overrides for the
|
||
// C++ patches in gbrowser's Chromium build. Each switch is a no-op
|
||
// on Chromium builds without the corresponding patch (the patch's
|
||
// empty-fallback returns native), so this is safe on stock Playwright
|
||
// Chromium too.
|
||
...buildGStackLaunchArgs(),
|
||
...this.proxyLaunchArgs(),
|
||
];
|
||
if (extensionPath) {
|
||
// Skip --load-extension when running against a custom Chromium build
|
||
// that already bakes the extension in as a component extension
|
||
// (gbrowser / GStack Browser.app). Loading it twice causes a
|
||
// ServiceWorkerState::SetWorkerId DCHECK crash.
|
||
if (!isCustomChromium()) {
|
||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||
}
|
||
// Write auth token for extension bootstrap (still required even when
|
||
// the extension is component-baked — it reads ~/.gstack/.auth.json at
|
||
// startup to learn how to call the daemon).
|
||
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
|
||
// in .app bundles and breaks codesigning).
|
||
if (authToken) {
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
|
||
mkdirSecure(gstackDir);
|
||
const authFile = path.join(gstackDir, '.auth.json');
|
||
try {
|
||
writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }));
|
||
} catch (err: any) {
|
||
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Launch headed Chromium via Playwright's persistent context.
|
||
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
|
||
// Real Chrome (executablePath/channel) silently blocks --load-extension,
|
||
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const userDataDir = resolveChromiumProfile();
|
||
ensureProjectProfile(userDataDir);
|
||
fs.mkdirSync(userDataDir, { recursive: true });
|
||
|
||
// Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's
|
||
// ProcessSingleton refuses to start when these exist from a prior crash
|
||
// (SIGKILL, hard crash) — the lockfiles point at a PID that may no longer
|
||
// exist. Shutdown cleanup doesn't run on hard crashes, so we clean here
|
||
// too. Safe under external coordination: gbd.lock for gbrowser,
|
||
// single-instance CLI check for gstack.
|
||
cleanSingletonLocks(userDataDir);
|
||
|
||
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
|
||
// Used by GStack Browser.app to point at the bundled Chromium.
|
||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||
|
||
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
|
||
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
|
||
// replace) and overwrote its Resources/*.icns is deliberately GONE.
|
||
// Chrome for Testing is a code-signed bundle: the global replace renamed
|
||
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
|
||
// writes broke the codesign seal — GPU process exit_code=5, headed mode
|
||
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
|
||
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
|
||
// of the signed bundle. Do not reintroduce writes into the Chromium
|
||
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
|
||
// you do.
|
||
//
|
||
// Self-heal for bundles the OLD code already poisoned: probe the
|
||
// Playwright-cache bundle and remove it when the mutated plist is
|
||
// present (see probePoisonedChromiumBundle for the removal-scope
|
||
// rationale). Scoped to the Playwright cache copy — a
|
||
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||
// never probed.
|
||
if (!executablePath) {
|
||
try {
|
||
probePoisonedChromiumBundle(chromium.executablePath());
|
||
} catch (err: unknown) {
|
||
if (err instanceof PoisonedBundleError) throw err;
|
||
// Probe failures (no bundle yet, EACCES) fall through to launch,
|
||
// which produces its own actionable error.
|
||
}
|
||
}
|
||
|
||
// Build custom user agent: report as stock Chrome with the version
|
||
// matching the underlying Chromium binary. D6 (codex #18 correction):
|
||
// the previous "GStackBrowser" branding suffix was itself a high-entropy
|
||
// classifier — sites grepping UA for known browser strings caught us
|
||
// immediately. Branding still lives in the wrapper .app name + Dock icon
|
||
// + tray; it does NOT need to be in the UA string for the product to be
|
||
// "GBrowser." Removing it resolves the "looks like Chrome but identifies
|
||
// as GStackBrowser" contradiction codex flagged.
|
||
let customUA: string | undefined;
|
||
if (!this.customUserAgent) {
|
||
// Detect Chrome version from the Chromium binary
|
||
const chromePath = executablePath || chromium.executablePath();
|
||
try {
|
||
const versionProc = Bun.spawnSync([chromePath, '--version'], {
|
||
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||
});
|
||
const versionOutput = versionProc.stdout.toString().trim();
|
||
// Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0"
|
||
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||
const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0';
|
||
customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
|
||
} catch {
|
||
// Fallback: generic modern Chrome UA
|
||
customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||
}
|
||
}
|
||
|
||
// T1: strip Playwright's automation-tell defaults. STEALTH_IGNORE_DEFAULT_ARGS
|
||
// covers the originals (extension-loading blockers) plus --enable-automation
|
||
// (kills the "Chrome is being controlled by automated test software" infobar
|
||
// and the chrome-runtime shape changes Playwright otherwise triggers) and
|
||
// three more (--disable-popup-blocking, --disable-component-update,
|
||
// --disable-default-apps — each a documented automation tell per Patchright).
|
||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||
// XProtect self-heal wrapper (P0 #2554). usesCustomExecutable scopes the
|
||
// heal out when GSTACK_CHROMIUM_PATH supplies the bundle — that bundle
|
||
// belongs to the wrapper/embedder and is never quarantine-cleared or
|
||
// reinstalled over (probePoisonedChromiumBundle's scope contract).
|
||
this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||
headless: false,
|
||
...(this.xvfb ? { env: { ...process.env, DISPLAY: this.xvfb.display } } : {}),
|
||
// #2220: daemon owns signal policy — see launch() for the rationale.
|
||
handleSIGINT: false,
|
||
handleSIGTERM: false,
|
||
handleSIGHUP: false,
|
||
// Match the sandbox policy used by launch() above. Without this,
|
||
// Playwright auto-adds --no-sandbox on every headed launch and the user
|
||
// sees Chromium's "unsupported command-line flag" yellow infobar.
|
||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||
args: launchArgs,
|
||
viewport: null, // Use browser's default viewport (real window size)
|
||
userAgent: this.customUserAgent || customUA,
|
||
...(executablePath ? { executablePath } : {}),
|
||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||
}), { usesCustomExecutable: Boolean(executablePath) });
|
||
this.browser = this.context.browser();
|
||
this.connectionMode = 'headed';
|
||
this.intentionalDisconnect = false;
|
||
|
||
// ─── Anti-bot-detection patches ───────────────────────────────
|
||
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
|
||
// restores window.chrome.* shape, aligns Notification.permission, sets
|
||
// per-install hardware, and strips automation runtime artifacts (cdc_/
|
||
// __webdriver globals + the Permissions notifications 'denied' tell).
|
||
// We still do NOT fake navigator.plugins/languages — faking those flags
|
||
// more bot-like, not less (D7). The cdc/Permissions cleanup moved into
|
||
// applyStealth so headless launch() and handoff() get it too, not just
|
||
// this headed path.
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(this.context);
|
||
|
||
// Inject visual indicator — subtle top-edge amber gradient
|
||
// Extension's content script handles the floating pill
|
||
const indicatorScript = () => {
|
||
const injectIndicator = () => {
|
||
if (document.getElementById('gstack-ctrl')) return;
|
||
|
||
const topLine = document.createElement('div');
|
||
topLine.id = 'gstack-ctrl';
|
||
topLine.style.cssText = `
|
||
position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
||
background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
|
||
background-size: 200% 100%;
|
||
animation: gstack-shimmer 3s linear infinite;
|
||
pointer-events: none; z-index: 2147483647;
|
||
opacity: 0.8;
|
||
`;
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes gstack-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
#gstack-ctrl { animation: none !important; }
|
||
}
|
||
`;
|
||
|
||
document.documentElement.appendChild(style);
|
||
document.documentElement.appendChild(topLine);
|
||
};
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', injectIndicator);
|
||
} else {
|
||
injectIndicator();
|
||
}
|
||
};
|
||
await this.context.addInitScript(indicatorScript);
|
||
|
||
// Track user-created tabs automatically (Cmd+T, link opens in new tab, etc.)
|
||
this.context.on('page', (page) => {
|
||
const id = this.nextTabId++;
|
||
this.pages.set(id, page);
|
||
this.tabSessions.set(id, new TabSession(page));
|
||
this.activeTabId = id;
|
||
this.wirePageEvents(page);
|
||
// Inject indicator on the new tab
|
||
page.evaluate(indicatorScript).catch(() => {});
|
||
console.log(`[browse] New tab detected (id=${id}, total=${this.pages.size})`);
|
||
this.checkTabGuardrails();
|
||
});
|
||
|
||
// Persistent context opens a default page — adopt it instead of creating a new one
|
||
const existingPages = this.context.pages();
|
||
if (existingPages.length > 0) {
|
||
const page = existingPages[0];
|
||
const id = this.nextTabId++;
|
||
this.pages.set(id, page);
|
||
this.tabSessions.set(id, new TabSession(page));
|
||
this.activeTabId = id;
|
||
this.wirePageEvents(page);
|
||
// Inject indicator on restored page (addInitScript only fires on new navigations)
|
||
try {
|
||
await page.evaluate(indicatorScript);
|
||
} catch {}
|
||
} else {
|
||
await this.newTab();
|
||
}
|
||
|
||
// Browser disconnect handler — distinguish user Cmd+Q from real crash.
|
||
// Clean exit (Chromium exit code 0) → process.exit(0) so process
|
||
// supervisors (gbrowser's gbd) treat it as user intent and skip the
|
||
// restart loop. Crash → process.exit(2) preserves the legacy headed
|
||
// semantics that's distinct from launch()'s code 1.
|
||
// Always calls onDisconnect() first to trigger full shutdown (kill
|
||
// terminal agent, save session, clean profile locks + state file) so
|
||
// crashes don't strand resources either.
|
||
if (this.browser) {
|
||
this.onBrowserDisconnected(this.browser, () => {
|
||
if (this.intentionalDisconnect) return;
|
||
const browserRef = this.browser;
|
||
void (async () => {
|
||
const cause = await resolveDisconnectCause(browserRef);
|
||
const exitCode = cause === 'clean' ? 0 : 2;
|
||
if (cause === 'clean') {
|
||
console.error('[browse] Real browser closed cleanly (user-initiated quit). Server exiting (0).');
|
||
} else {
|
||
console.error('[browse] Real browser disconnected (crash or kill). Server exiting (2).');
|
||
console.error('[browse] Run `$B connect` to reconnect.');
|
||
}
|
||
if (!this.onDisconnect) {
|
||
process.exit(exitCode);
|
||
return;
|
||
}
|
||
try {
|
||
const result = this.onDisconnect(exitCode);
|
||
if (result && typeof (result as Promise<void>).catch === 'function') {
|
||
(result as Promise<void>).catch((err) => {
|
||
console.error('[browse] onDisconnect rejected:', err);
|
||
process.exit(exitCode);
|
||
});
|
||
}
|
||
// onDisconnect is responsible for exit on the success path.
|
||
} catch (err) {
|
||
console.error('[browse] onDisconnect threw:', err);
|
||
process.exit(exitCode);
|
||
}
|
||
})();
|
||
});
|
||
}
|
||
|
||
// Headed mode defaults
|
||
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
|
||
this.isHeaded = true;
|
||
this.consecutiveFailures = 0;
|
||
}
|
||
|
||
// How long close() waits for a graceful shutdown before falling back to
|
||
// SIGKILL (launched mode) or abandoning the context close (headed mode).
|
||
// A field, not a literal, so the SIGKILL fallback is unit-testable without
|
||
// a 5-second wait.
|
||
private closeRaceMs = 5000;
|
||
|
||
async close() {
|
||
this.closing = true;
|
||
const previousBrowser = this.handoffPrevious?.browser;
|
||
const currentBrowser = this.browser;
|
||
// unref'd race timer: without unref, every successful close still pins
|
||
// the caller's event loop for the full window.
|
||
const raceTimeout = (ms: number) => new Promise<false>((resolve) => {
|
||
const t = setTimeout(() => resolve(false), ms);
|
||
(t as { unref?: () => void }).unref?.();
|
||
});
|
||
if (this.browser || ((this.connectionMode === 'headed' || this.handoffPrevious) && this.context)) {
|
||
if (this.connectionMode === 'headed' || this.handoffPrevious || this.extensionContext) {
|
||
// Headed/persistent context mode: close the context (which closes the browser)
|
||
this.intentionalDisconnect = true;
|
||
this.detachDisconnectHandlers(this.browser);
|
||
await Promise.race([
|
||
this.context ? this.context.close() : Promise.resolve(),
|
||
raceTimeout(this.closeRaceMs),
|
||
]).catch(() => {});
|
||
} else if (this.browser) {
|
||
// Launched mode: close the browser we spawned.
|
||
this.detachDisconnectHandlers(this.browser);
|
||
// Grab the child handle BEFORE the race: nulling this.browser after a
|
||
// race-timeout used to ABANDON a live Chromium whose sockets kept the
|
||
// caller's event loop (and keep-alive connections into test servers)
|
||
// open forever — the intermittent whole-suite wedge. If graceful close
|
||
// doesn't finish in time, the child gets SIGKILL, not freedom.
|
||
const child = launchedProcess(this.browser);
|
||
const closed = await Promise.race([
|
||
this.browser.close().then(() => true as const),
|
||
raceTimeout(this.closeRaceMs),
|
||
]).catch(() => false as const);
|
||
if (closed === false && child && child.exitCode === null && !child.killed) {
|
||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||
}
|
||
}
|
||
this.browser = null;
|
||
}
|
||
if (previousBrowser && previousBrowser !== currentBrowser) {
|
||
this.detachDisconnectHandlers(previousBrowser);
|
||
const child = launchedProcess(previousBrowser);
|
||
const closed = await Promise.race([
|
||
previousBrowser.close().then(() => true), raceTimeout(this.closeRaceMs),
|
||
]).catch(() => false);
|
||
if (!closed && child && child.exitCode === null && !child.killed) {
|
||
try { child.kill('SIGKILL'); } catch {}
|
||
}
|
||
}
|
||
await this.displayAllocation?.catch(() => {});
|
||
this.closeOwnedDisplay();
|
||
}
|
||
|
||
/**
|
||
* Daemon liveness for /health: Chromium is connected. The active tab is NOT
|
||
* part of it (#3247): a page stuck in a long script is one busy tab, not a
|
||
* dead daemon, and the CLI gates every command on this answer — including
|
||
* the tabs, tab, closetab and stop commands that get out of that state.
|
||
* probeActiveTab() reports the tab on its own.
|
||
*/
|
||
isHealthy(): boolean {
|
||
return Boolean(this.browser?.isConnected());
|
||
}
|
||
|
||
/**
|
||
* Does the active tab's main thread answer within `timeoutMs`? Null when
|
||
* there is no active page. A rejected evaluate (crashed or closing target)
|
||
* also counts as not responsive.
|
||
*/
|
||
async probeActiveTab(timeoutMs = TAB_PROBE_TIMEOUT_MS): Promise<{ id: number; responsive: boolean } | null> {
|
||
const id = this.activeTabId;
|
||
const page = this.pages.get(id);
|
||
if (!page) return null;
|
||
const probe = this.sharedTabRead(page, 'probe', () => page.evaluate('1').then(() => true, () => false));
|
||
return { id, responsive: (await withinTabBudget(probe, timeoutMs)) === true };
|
||
}
|
||
|
||
/** Join the page's pending read of this kind, or start one. `read` must not
|
||
* reject. */
|
||
private sharedTabRead<T>(page: Page, kind: TabReadKind, read: () => Promise<T>): Promise<T> {
|
||
let reads = this.pendingTabReads.get(page);
|
||
if (!reads) {
|
||
reads = new Map();
|
||
this.pendingTabReads.set(page, reads);
|
||
}
|
||
const pending = reads.get(kind);
|
||
if (pending) return pending as Promise<T>;
|
||
const started = read().finally(() => reads.delete(kind));
|
||
reads.set(kind, started);
|
||
return started;
|
||
}
|
||
|
||
// ─── Tab Management ────────────────────────────────────────
|
||
async newTab(url?: string, clientId?: string): Promise<number> {
|
||
if (!this.context) throw new Error('Browser not launched');
|
||
|
||
// Validate URL before allocating page to avoid zombie tabs on rejection.
|
||
// Use the normalized return value for navigation — it handles file://./x and
|
||
// file://<segment> cwd-relative forms that the standard URL parser doesn't.
|
||
let normalizedUrl: string | undefined;
|
||
if (url) {
|
||
normalizedUrl = await validateNavigationUrl(url);
|
||
}
|
||
|
||
const activeBefore = this.activeTabId;
|
||
const page = await this.context.newPage();
|
||
let id = [...this.pages].find(([, p]) => p === page)?.[0];
|
||
const adopted = id !== undefined;
|
||
if (id === undefined) {
|
||
id = this.nextTabId++;
|
||
this.pages.set(id, page);
|
||
this.tabSessions.set(id, new TabSession(page));
|
||
}
|
||
const scope = this.tabScope.getStore();
|
||
if (scope) {
|
||
scope.tabId = id;
|
||
if (this.activeTabId === id) this.activeTabId = activeBefore;
|
||
} else this.activeTabId = id;
|
||
|
||
// Record tab ownership for multi-agent isolation
|
||
const owner = clientId ?? scope?.owner;
|
||
if (owner) {
|
||
this.tabOwnership.set(id, owner);
|
||
}
|
||
|
||
// Wire up console/network/dialog capture
|
||
if (!adopted) this.wirePageEvents(page);
|
||
|
||
if (normalizedUrl) {
|
||
await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||
}
|
||
|
||
return id;
|
||
}
|
||
|
||
async closeTab(id?: number): Promise<void> {
|
||
const tabId = id ?? this.getActiveTabId();
|
||
const page = this.pages.get(tabId);
|
||
if (!page) throw new Error(`Tab ${tabId} not found`);
|
||
const scope = this.tabScope.getStore();
|
||
|
||
// Capture BEFORE close(): the page 'close' event handler wired in
|
||
// wirePageEvents() can fire while page.close() is awaited. It removes
|
||
// the tab from the maps and reassigns activeTabId (to 0 when no tabs
|
||
// remain), so a post-close `tabId === this.activeTabId` check is
|
||
// order-dependent — whether the event dispatches before or after
|
||
// close() resolves varies across Playwright/Chromium versions and
|
||
// machines, and losing the race means the last-tab auto-create below
|
||
// never runs, leaving the manager with zero tabs.
|
||
const wasActive = tabId === this.getActiveTabId();
|
||
|
||
await page.close();
|
||
this.pages.delete(tabId);
|
||
this.tabSessions.delete(tabId);
|
||
this.tabOwnership.delete(tabId);
|
||
|
||
// Switch to another tab if we closed the active one. A scoped request moves
|
||
// only its own scope, to another tab of the same owner when there is one.
|
||
if (wasActive && scope && this.pages.size > 0) {
|
||
const ownedTabs = scope.owner ? this.tabsOwnedBy(scope.owner) : [];
|
||
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
|
||
} else if (wasActive) {
|
||
const remaining = [...this.pages.keys()];
|
||
if (remaining.length === 0) {
|
||
// No tabs left — create a new blank one
|
||
await this.newTab();
|
||
} else if (!this.pages.has(this.activeTabId)) {
|
||
// The 'close' handler may have already switched to a valid tab;
|
||
// only reassign when activeTabId no longer points at a live tab.
|
||
this.activeTabId = remaining[remaining.length - 1];
|
||
}
|
||
}
|
||
}
|
||
|
||
switchTab(id: number, opts?: { bringToFront?: boolean }): void {
|
||
if (!this.tabSessions.has(id)) throw new Error(`Tab ${id} not found`);
|
||
const scope = this.tabScope.getStore();
|
||
if (scope) scope.tabId = scope.picked = id;
|
||
else this.activeTabId = id;
|
||
// Only bring to front when explicitly requested (user-initiated tab switch).
|
||
// Internal tab pinning (BROWSE_TAB) should NOT steal focus.
|
||
if (opts?.bringToFront !== false) {
|
||
const page = this.pages.get(id);
|
||
if (page) page.bringToFront().catch(() => {});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sync activeTabId to match the tab whose URL matches the Chrome extension's
|
||
* active tab. Called on every /sidebar-tabs poll so manual tab switches in
|
||
* the browser are detected within ~2s.
|
||
*/
|
||
syncActiveTabByUrl(activeUrl: string): void {
|
||
if (!activeUrl || this.pages.size <= 1) return;
|
||
// Try exact match first, then fuzzy match (origin+pathname, ignoring query/fragment)
|
||
let fuzzyId: number | null = null;
|
||
let activeOriginPath = '';
|
||
try {
|
||
const u = new URL(activeUrl);
|
||
activeOriginPath = u.origin + u.pathname;
|
||
} catch (err: any) {
|
||
if (!(err instanceof TypeError)) throw err;
|
||
}
|
||
|
||
for (const [id, page] of this.pages) {
|
||
try {
|
||
const pageUrl = page.url();
|
||
// Exact match — best case
|
||
if (pageUrl === activeUrl && id !== this.activeTabId) {
|
||
this.activeTabId = id;
|
||
return;
|
||
}
|
||
// Fuzzy match — origin+pathname (handles query param / fragment differences)
|
||
if (activeOriginPath && fuzzyId === null && id !== this.activeTabId) {
|
||
try {
|
||
const pu = new URL(pageUrl);
|
||
if (pu.origin + pu.pathname === activeOriginPath) {
|
||
fuzzyId = id;
|
||
}
|
||
} catch (err: any) {
|
||
if (!(err instanceof TypeError)) throw err;
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
// Fall back to fuzzy match
|
||
if (fuzzyId !== null) {
|
||
this.activeTabId = fuzzyId;
|
||
}
|
||
}
|
||
|
||
getActiveTabId(): number {
|
||
const scope = this.tabScope.getStore();
|
||
return scope ? this.resolveScopeTab(scope) : this.activeTabId;
|
||
}
|
||
|
||
/** The scope's tab; an owned scope whose tab is gone (context rebuild) or neither owned nor picked with `tab <id>` moves to its owner's newest tab. */
|
||
resolveScopeTab(scope: TabScope): number {
|
||
for (let hops = 0; hops < 64 && !this.pages.has(scope.tabId) && this.restoredTabIds.has(scope.tabId); hops++) {
|
||
const moved = this.restoredTabIds.get(scope.tabId)!;
|
||
if (scope.picked === scope.tabId) scope.picked = moved;
|
||
scope.tabId = moved;
|
||
}
|
||
const kept = this.tabOwnership.get(scope.tabId) === scope.owner || scope.picked === scope.tabId;
|
||
if (scope.owner && !(this.pages.has(scope.tabId) && kept)) {
|
||
const ownedTabs = this.tabsOwnedBy(scope.owner);
|
||
if (ownedTabs.length > 0) scope.tabId = ownedTabs[ownedTabs.length - 1]!;
|
||
}
|
||
return scope.tabId;
|
||
}
|
||
|
||
tabUrl(tabId: number): string | null {
|
||
return this.pages.get(tabId)?.url() ?? null;
|
||
}
|
||
|
||
/** Run fn with every active-tab lookup bound to scope.tabId (see tabScope). */
|
||
runInTab<T>(scope: TabScope, fn: () => Promise<T>): Promise<T> {
|
||
return this.tabScope.run(scope, fn);
|
||
}
|
||
|
||
hasTab(tabId: number): boolean {
|
||
return this.pages.has(tabId);
|
||
}
|
||
|
||
tabsOwnedBy(owner: string): number[] {
|
||
return [...this.tabOwnership].filter(([id, o]) => o === owner && this.pages.has(id)).map(([id]) => id).sort((a, b) => a - b);
|
||
}
|
||
|
||
getTabCount(): number {
|
||
return this.pages.size;
|
||
}
|
||
|
||
// ─── Tab Ownership (multi-agent isolation) ──────────────
|
||
|
||
/** Get the owner of a tab, or null if unowned (root-only for writes). */
|
||
getTabOwner(tabId: number): string | null {
|
||
return this.tabOwnership.get(tabId) || null;
|
||
}
|
||
|
||
/**
|
||
* Check if a client can access a tab.
|
||
*
|
||
* Two policies, distinguished by `options.ownOnly`:
|
||
*
|
||
* - **own-only (pair-agent over tunnel):** the strict mode. Token must own
|
||
* the target tab for any access (reads or writes). Unowned user tabs
|
||
* and tabs owned by other clients are off-limits. Remote agents must
|
||
* `newtab` first to get a tab they can drive.
|
||
*
|
||
* - **shared (local skill spawns, default scoped tokens):** permissive on
|
||
* tab access. The token can read/write any tab — capability is gated
|
||
* elsewhere (scope checks at /command, rate limits, the dual-listener
|
||
* allowlist for tunnel-bound traffic). Tab ownership is not a security
|
||
* boundary for shared tokens; it only matters for pair-agent isolation.
|
||
* This matches the contract documented in `skill-token.ts:79`
|
||
* ("skill scripts may switch tabs as needed").
|
||
*
|
||
* Root is unconstrained.
|
||
*
|
||
* `isWrite` is preserved in the signature for callers that want to log or
|
||
* branch on it elsewhere, but the access decision itself only depends on
|
||
* `ownOnly` + ownership map state.
|
||
*/
|
||
checkTabAccess(tabId: number, clientId: string, options: { isWrite?: boolean; ownOnly?: boolean } = {}): boolean {
|
||
if (clientId === 'root') return true;
|
||
if (options.ownOnly) {
|
||
const owner = this.tabOwnership.get(tabId);
|
||
return owner === clientId;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Transfer tab ownership to a different client. */
|
||
transferTab(tabId: number, toClientId: string): void {
|
||
if (!this.pages.has(tabId)) throw new Error(`Tab ${tabId} not found`);
|
||
this.tabOwnership.set(tabId, toClientId);
|
||
}
|
||
|
||
/**
|
||
* Release all tab ownership held by a client (called on revoke / reducing
|
||
* re-pair). Deletes the ownership entries so an own-only client re-pairing
|
||
* under the same name can no longer inherit the revoked agent's
|
||
* authenticated tabs (checkTabAccess gates own-only on owner === clientId).
|
||
* Tabs are NOT closed — leaving the page open is the local human's call, not
|
||
* the daemon's; the residual is a root-only tab. Returns the released ids.
|
||
*/
|
||
releaseClientTabs(clientId: string): number[] {
|
||
const released: number[] = [];
|
||
for (const [tabId, owner] of this.tabOwnership) {
|
||
if (owner === clientId) {
|
||
this.tabOwnership.delete(tabId);
|
||
released.push(tabId);
|
||
}
|
||
}
|
||
return released;
|
||
}
|
||
|
||
/**
|
||
* Every tab with its title. Titles are read in parallel, each bounded by
|
||
* TAB_PROBE_TIMEOUT_MS: a tab whose renderer is stuck comes back with
|
||
* `busy: true` and an empty title instead of hanging the list (#3247).
|
||
*/
|
||
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean; busy: boolean; owner: string | null }>> {
|
||
const activeId = this.getActiveTabId();
|
||
return Promise.all([...this.pages].map(async ([id, page]) => {
|
||
const title = await withinTabBudget(
|
||
this.sharedTabRead(page, 'title', () => page.title().catch(() => '')), TAB_PROBE_TIMEOUT_MS);
|
||
return {
|
||
id,
|
||
url: page.url(),
|
||
title: title ?? '',
|
||
active: id === activeId,
|
||
busy: title === undefined,
|
||
owner: this.tabOwnership.get(id) ?? null,
|
||
};
|
||
}));
|
||
}
|
||
|
||
// ─── Session Access ────────────────────────────────────────
|
||
/**
|
||
* Restore an active tab when every page was closed outside the CLI (for
|
||
* example, the user closed the headed window). The page 'close' handler is
|
||
* synchronous and cannot open a replacement, which left activeTabId at 0 and
|
||
* every later command, including status and stop, failing with "No active
|
||
* page". closeTab() already opens a blank tab for the last-tab case; this
|
||
* covers the external path. Concurrent callers share one recovery. Returns
|
||
* true when a new tab had to be opened.
|
||
*/
|
||
async ensureActiveTab(): Promise<boolean> {
|
||
if (this.tabSessions.has(this.getActiveTabId())) return false;
|
||
const remaining = [...this.tabSessions.keys()];
|
||
if (remaining.length > 0) {
|
||
this.activeTabId = remaining[remaining.length - 1]!;
|
||
return false;
|
||
}
|
||
if (!this.context) throw new Error('Browser not launched');
|
||
this.activeTabRecovery ??= this.newTab()
|
||
.then(() => {})
|
||
.finally(() => { this.activeTabRecovery = null; });
|
||
await this.activeTabRecovery;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Called before every command runs (#3167). status, stop and restart never
|
||
* open a tab, so a daemon whose browser or context is gone can still report
|
||
* and be stopped. Any other command gets an active tab: a failed recovery is
|
||
* BROWSE_TAB_UNRECOVERABLE with the restart command, and a command that runs
|
||
* on a newly opened tab gets the returned one-line warning.
|
||
*/
|
||
async ensureCommandTab(command: string): Promise<string | undefined> {
|
||
if (BrowserManager.NO_TAB_RECOVERY.has(command)) return undefined;
|
||
try {
|
||
if (!(await this.ensureActiveTab())) return undefined;
|
||
} catch (err: any) {
|
||
throw new Error(resultLine('browse', 'BROWSE_TAB_UNRECOVERABLE', sanitizeEcho(err?.message ?? String(err))));
|
||
}
|
||
return '[browse] warning: every tab was closed outside browse, so this command ran on a new blank tab; open your page again with: browse goto <url>';
|
||
}
|
||
private static readonly NO_TAB_RECOVERY = new Set(['status', 'stop', 'restart']);
|
||
|
||
/** Get the TabSession for the active tab. */
|
||
getActiveSession(): TabSession {
|
||
const session = this.tabSessions.get(this.getActiveTabId());
|
||
if (!session) throw new Error('No active page. Use "browse goto <url>" first.');
|
||
return session;
|
||
}
|
||
|
||
/** Get a TabSession by tab ID. Used by /batch for parallel tab execution. */
|
||
getSession(tabId: number): TabSession {
|
||
const session = this.tabSessions.get(tabId);
|
||
if (!session) throw new Error(`Tab ${tabId} not found`);
|
||
return session;
|
||
}
|
||
|
||
/** Get the underlying Page for a tab id. Returns null if the tab doesn't exist.
|
||
* Used by the CDP bridge (cdp-bridge.ts) to mint per-tab CDPSessions. */
|
||
getPageForTab(tabId: number): Page | null {
|
||
return this.pages.get(tabId) ?? null;
|
||
}
|
||
|
||
// ─── Two-tier mutex (Codex T7) ─────────────────────────────
|
||
// Per-tab and global locks for the CDP bridge. tab-scoped methods take the
|
||
// per-tab mutex; browser-scoped methods take the global lock that blocks all
|
||
// tab mutexes. Hard timeout on acquire so silent deadlock can't happen.
|
||
// Every caller MUST use try { ... } finally { release() }.
|
||
|
||
private tabLocks: Map<number, Promise<void>> = new Map();
|
||
private globalCdpLockTail: Promise<void> = Promise.resolve();
|
||
|
||
/**
|
||
* Acquire the per-tab CDP lock with a timeout. Returns a release fn.
|
||
* Locks chain: each acquire waits on the prior tail's resolution.
|
||
* Browser-scoped global lock takes precedence: while the global lock is
|
||
* held, no tab lock can be acquired (and vice versa).
|
||
*/
|
||
async acquireTabLock(tabId: number, timeoutMs: number): Promise<() => void> {
|
||
const existing = this.tabLocks.get(tabId) ?? Promise.resolve();
|
||
// Wait for any held global lock first (cross-tier serialization).
|
||
const tail = Promise.all([existing, this.globalCdpLockTail]).then(() => undefined);
|
||
let release!: () => void;
|
||
const next = new Promise<void>((resolve) => { release = resolve; });
|
||
this.tabLocks.set(tabId, tail.then(() => next));
|
||
|
||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||
setTimeout(() => reject(new Error(
|
||
`CDPMutexAcquireTimeout: tab ${tabId} lock not acquired within ${timeoutMs}ms.\n` +
|
||
'Cause: a prior CDP or browser-scoped operation has held the lock too long.\n' +
|
||
'Action: retry; if this repeats, the prior operation may be hung — file a bug.'
|
||
)), timeoutMs),
|
||
);
|
||
try {
|
||
await Promise.race([tail, timeoutPromise]);
|
||
} catch (e) {
|
||
// Acquisition failed; release the slot we reserved so we don't deadlock the queue.
|
||
release();
|
||
throw e;
|
||
}
|
||
return release;
|
||
}
|
||
|
||
/**
|
||
* Acquire the global CDP lock. Blocks until all tab locks are released, and
|
||
* blocks new tab-lock acquisitions until released.
|
||
*/
|
||
async acquireGlobalCdpLock(timeoutMs: number): Promise<() => void> {
|
||
const allTabTails = Array.from(this.tabLocks.values());
|
||
const priorGlobal = this.globalCdpLockTail;
|
||
const allPrior = Promise.all([priorGlobal, ...allTabTails]).then(() => undefined);
|
||
let release!: () => void;
|
||
const next = new Promise<void>((resolve) => { release = resolve; });
|
||
this.globalCdpLockTail = allPrior.then(() => next);
|
||
|
||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||
setTimeout(() => reject(new Error(
|
||
`CDPMutexAcquireTimeout: global CDP lock not acquired within ${timeoutMs}ms.\n` +
|
||
'Cause: in-flight tab operations have not completed.\n' +
|
||
'Action: retry; if this repeats, file a bug — a tab op may be hung.'
|
||
)), timeoutMs),
|
||
);
|
||
try {
|
||
await Promise.race([allPrior, timeoutPromise]);
|
||
} catch (e) {
|
||
release();
|
||
throw e;
|
||
}
|
||
return release;
|
||
}
|
||
|
||
// ─── Page Access (delegates to active session) ─────────────
|
||
getPage(): Page {
|
||
return this.getActiveSession().page;
|
||
}
|
||
|
||
getCurrentUrl(): string {
|
||
try {
|
||
return this.getPage().url();
|
||
} catch {
|
||
return 'about:blank';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Diagnostic for `$B memory` and the /memory endpoint.
|
||
*
|
||
* Collects:
|
||
* - Bun process memory (cross-platform, accurate, no shelling).
|
||
* - Per-tab JS heap via CDP Performance.getMetrics — the most portable
|
||
* per-tab signal CDP exposes. Misses native/GPU/Skia/cache memory
|
||
* (Codex flag on the eng-review; see follow-up TODO "native/GPU
|
||
* memory breakdown").
|
||
* - Chromium process tree via SystemInfo.getProcessInfo — PID + type
|
||
* + CPU time. Per-process RSS is NOT exposed via CDP and the eng
|
||
* review (D2 USE_CDP) explicitly chose CDP over shelling to `ps`,
|
||
* so RSS columns are absent and `notes[]` says why.
|
||
*
|
||
* `structures` is passed in by the caller (read-commands / server) so
|
||
* browser-manager doesn't take a hard dep on every buffer-owning module.
|
||
*/
|
||
async getMemorySnapshot(structures: MemoryStructureStats): Promise<MemorySnapshot> {
|
||
const bunMem = process.memoryUsage();
|
||
const notes: string[] = [];
|
||
|
||
// Per-tab JS heap. Lazy: only the pages we already track. A target
|
||
// that died mid-snapshot is omitted, never throws.
|
||
const tabs: MemoryTabSnapshot[] = [];
|
||
for (const [id, page] of this.pages) {
|
||
try {
|
||
const url = (() => { try { return page.url(); } catch { return ''; } })();
|
||
const title = await page.title().catch(() => '');
|
||
const metrics = await withCdpSession(page, async (session) => {
|
||
await session.send('Performance.enable').catch(() => undefined);
|
||
const result = await session.send('Performance.getMetrics');
|
||
return ((result as { metrics?: Array<{ name: string; value: number }> }).metrics) ?? [];
|
||
});
|
||
const mm: Record<string, number> = {};
|
||
for (const m of metrics) mm[m.name] = m.value;
|
||
tabs.push({
|
||
id,
|
||
url,
|
||
title,
|
||
jsHeapUsed: mm.JSHeapUsedSize ?? 0,
|
||
jsHeapTotal: mm.JSHeapTotalSize ?? 0,
|
||
documents: mm.Documents ?? 0,
|
||
nodes: mm.Nodes ?? 0,
|
||
listeners: mm.JSEventListeners ?? 0,
|
||
});
|
||
} catch {
|
||
// Target died or CDP unavailable mid-snapshot — skip this tab.
|
||
}
|
||
}
|
||
|
||
// Chromium process tree. Browser handle may be on the `browser` field
|
||
// (launched mode) or accessible via `context.browser()` (persistent
|
||
// context / headed mode); try both.
|
||
let processes: MemoryProcess[] | null = null;
|
||
const browser: Browser | null = this.browser ?? (this.context ? this.context.browser() : null);
|
||
if (browser) {
|
||
try {
|
||
// `newBrowserCDPSession` is browser-wide. Not exposed on every
|
||
// Playwright TypeScript surface, but present at runtime on the
|
||
// Browser instance — use a typed cast to avoid the @ts-expect-error.
|
||
type BrowserWithCDP = Browser & {
|
||
newBrowserCDPSession?: () => Promise<{
|
||
send: (method: string, params?: unknown) => Promise<unknown>;
|
||
detach: () => Promise<void>;
|
||
}>;
|
||
};
|
||
const maybeFactory = (browser as BrowserWithCDP).newBrowserCDPSession;
|
||
if (typeof maybeFactory === 'function') {
|
||
const browserSession = await maybeFactory.call(browser);
|
||
try {
|
||
const info = (await browserSession.send('SystemInfo.getProcessInfo')) as {
|
||
processInfo?: Array<{ id: number; type: string; cpuTime: number }>;
|
||
};
|
||
processes = (info.processInfo ?? []).map((p) => ({
|
||
id: p.id,
|
||
type: p.type,
|
||
cpuTime: p.cpuTime,
|
||
}));
|
||
notes.push(
|
||
'Per-Chromium-process RSS not collected — SystemInfo.getProcessInfo exposes PID+type+CPU only. ' +
|
||
'See follow-up TODO "native/GPU memory breakdown" for the deferred fix.',
|
||
);
|
||
} finally {
|
||
await browserSession.detach().catch(() => undefined);
|
||
}
|
||
} else {
|
||
notes.push('Playwright build does not expose newBrowserCDPSession; per-process info skipped.');
|
||
}
|
||
} catch (err: any) {
|
||
notes.push(`CDP browser session unavailable: ${err?.message ?? String(err)}`);
|
||
}
|
||
} else {
|
||
notes.push('Browser handle unavailable (server connection mode); per-process info skipped.');
|
||
}
|
||
|
||
return {
|
||
bunServer: {
|
||
rss: bunMem.rss,
|
||
heapUsed: bunMem.heapUsed,
|
||
heapTotal: bunMem.heapTotal,
|
||
external: bunMem.external,
|
||
},
|
||
tabs,
|
||
processes,
|
||
structures,
|
||
capturedAt: Date.now(),
|
||
notes,
|
||
};
|
||
}
|
||
|
||
// ─── Ref Map (delegates to active session) ──────────────────
|
||
setRefMap(refs: Map<string, RefEntry>) {
|
||
this.getActiveSession().setRefMap(refs);
|
||
}
|
||
|
||
clearRefs() {
|
||
this.getActiveSession().clearRefs();
|
||
}
|
||
|
||
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
||
return this.getActiveSession().resolveRef(selector);
|
||
}
|
||
|
||
getRefRole(selector: string): string | null {
|
||
return this.getActiveSession().getRefRole(selector);
|
||
}
|
||
|
||
getRefCount(): number {
|
||
return this.getActiveSession().getRefCount();
|
||
}
|
||
|
||
// ─── Snapshot Diffing (delegates to active session) ─────────
|
||
setLastSnapshot(text: string | null) {
|
||
this.getActiveSession().setLastSnapshot(text);
|
||
}
|
||
|
||
getLastSnapshot(): string | null {
|
||
return this.getActiveSession().getLastSnapshot();
|
||
}
|
||
|
||
// ─── Dialog Control ───────────────────────────────────────
|
||
setDialogAutoAccept(accept: boolean) {
|
||
this.dialogAutoAccept = accept;
|
||
}
|
||
|
||
getDialogAutoAccept(): boolean {
|
||
return this.dialogAutoAccept;
|
||
}
|
||
|
||
setDialogPromptText(text: string | null) {
|
||
this.dialogPromptText = text;
|
||
}
|
||
|
||
getDialogPromptText(): string | null {
|
||
return this.dialogPromptText;
|
||
}
|
||
|
||
// ─── Cookie Origin Tracking ────────────────────────────────
|
||
trackCookieImportDomains(domains: string[]): void {
|
||
for (const d of domains) this.cookieImportedDomains.add(d);
|
||
}
|
||
|
||
getCookieImportedDomains(): ReadonlySet<string> {
|
||
return this.cookieImportedDomains;
|
||
}
|
||
|
||
hasCookieImports(): boolean {
|
||
return this.cookieImportedDomains.size > 0;
|
||
}
|
||
|
||
// ─── Viewport ──────────────────────────────────────────────
|
||
async setViewport(width: number, height: number) {
|
||
this.currentViewport = { width, height };
|
||
await this.getPage().setViewportSize({ width, height });
|
||
}
|
||
|
||
/**
|
||
* Undo a `viewport WxH` pin. Headless contexts restore the 1280x720 launch
|
||
* default. Headed contexts launch with `viewport: null`, but Playwright has no
|
||
* way to drop a page's emulated size, so the active tab gets a fresh page in
|
||
* the same context (cookies and storage persist, refs reset as on navigation).
|
||
*/
|
||
async resetViewport(): Promise<'window' | 'default'> {
|
||
this.currentViewport = { width: 1280, height: 720 };
|
||
if (this.connectionMode !== 'headed') {
|
||
await this.getPage().setViewportSize(this.currentViewport);
|
||
return 'default';
|
||
}
|
||
if (!this.context) throw new Error('Browser not launched');
|
||
|
||
const tabId = this.getActiveTabId();
|
||
const pinned = this.getPage();
|
||
const loaded = this.getActiveSession().getLoadedHtml();
|
||
const url = pinned.url();
|
||
const activeBefore = this.activeTabId;
|
||
const page = await this.context.newPage();
|
||
const listenerId = [...this.pages].find(([, p]) => p === page)?.[0];
|
||
if (listenerId !== undefined) {
|
||
this.pages.delete(listenerId);
|
||
this.tabSessions.delete(listenerId);
|
||
}
|
||
this.activeTabId = activeBefore;
|
||
const session = new TabSession(page);
|
||
if (listenerId === undefined) this.wirePageEvents(page);
|
||
try {
|
||
if (loaded) await session.setTabContent(loaded.html, { waitUntil: loaded.waitUntil });
|
||
else if (url !== 'about:blank') await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||
} catch (err) {
|
||
await page.close().catch(() => {});
|
||
throw err;
|
||
}
|
||
this.pages.set(tabId, page);
|
||
this.tabSessions.set(tabId, session);
|
||
await pinned.close().catch(() => {});
|
||
await page.bringToFront().catch(() => {});
|
||
return 'window';
|
||
}
|
||
|
||
// ─── Extra Headers ─────────────────────────────────────────
|
||
async setExtraHeader(name: string, value: string) {
|
||
this.extraHeaders[name] = value;
|
||
if (this.context) {
|
||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||
}
|
||
}
|
||
|
||
// ─── User Agent ────────────────────────────────────────────
|
||
setUserAgent(ua: string) {
|
||
this.customUserAgent = ua;
|
||
}
|
||
|
||
getUserAgent(): string | null {
|
||
return this.customUserAgent;
|
||
}
|
||
|
||
// ─── Lifecycle helpers ───────────────────────────────
|
||
/**
|
||
* Close all open pages and clear the pages map.
|
||
* Used by state load to replace the current session.
|
||
*/
|
||
async closeAllPages(): Promise<void> {
|
||
for (const page of this.pages.values()) {
|
||
await page.close().catch(() => {});
|
||
}
|
||
this.pages.clear();
|
||
this.tabSessions.clear();
|
||
}
|
||
|
||
// ─── Frame context (delegates to active session) ────────────
|
||
setFrame(frame: import('playwright').Frame | null): void {
|
||
this.getActiveSession().setFrame(frame);
|
||
}
|
||
|
||
getFrame(): import('playwright').Frame | null {
|
||
return this.getActiveSession().getFrame();
|
||
}
|
||
|
||
getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
|
||
return this.getActiveSession().getActiveFrameOrPage();
|
||
}
|
||
|
||
// ─── State Save/Restore (shared by recreateContext + handoff) ─
|
||
/**
|
||
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
||
* Skips the storage of pages that fail the read (e.g., already closed).
|
||
* A page whose renderer is stuck past TAB_STORAGE_READ_TIMEOUT_MS does not
|
||
* hold up `state save` (#3247); see readTabStorage() for what it gets.
|
||
*/
|
||
async saveState(): Promise<BrowserState> {
|
||
if (!this.context) throw new Error('Browser not launched');
|
||
|
||
const cookies = await this.context.cookies();
|
||
const pages: BrowserState['pages'] = [];
|
||
|
||
for (const [id, page] of this.pages) {
|
||
const url = page.url();
|
||
const storage = await this.readTabStorage(page, url);
|
||
|
||
// Capture load-html content so a later context recreation (viewport --scale)
|
||
// can replay it via setTabContent. Never persisted to disk.
|
||
const session = this.tabSessions.get(id);
|
||
const loaded = session?.getLoadedHtml();
|
||
// Preserve tab ownership through recreation so scoped agents aren't locked out.
|
||
const owner = this.tabOwnership.get(id);
|
||
|
||
pages.push({
|
||
url: url === 'about:blank' ? '' : url,
|
||
isActive: id === this.activeTabId,
|
||
storage,
|
||
loadedHtml: loaded?.html,
|
||
loadedHtmlWaitUntil: loaded?.waitUntil,
|
||
owner,
|
||
device: session?.getDevice() ?? undefined,
|
||
tabId: id,
|
||
});
|
||
}
|
||
|
||
return { cookies, pages };
|
||
}
|
||
|
||
/**
|
||
* One tab's localStorage/sessionStorage for saveState(). A failed read
|
||
* (closed page, opaque origin) is null. A read that times out returns the
|
||
* last storage this page gave for the same origin: a periodic
|
||
* BROWSE_PERSIST_STATE snapshot must not replace good storage on disk with
|
||
* null just because the page is busy.
|
||
*/
|
||
private async readTabStorage(page: Page, url: string): Promise<TabStorage | null> {
|
||
const origin = urlOrigin(url);
|
||
const read = await withinTabBudget(this.sharedTabRead(page, 'storage', () => page.evaluate(() => ({
|
||
localStorage: { ...localStorage },
|
||
sessionStorage: { ...sessionStorage },
|
||
})).catch(() => null)), TAB_STORAGE_READ_TIMEOUT_MS);
|
||
if (read === undefined) {
|
||
const last = this.lastTabStorage.get(page);
|
||
return last?.origin === origin ? last.storage : null;
|
||
}
|
||
if (read) this.lastTabStorage.set(page, { origin, storage: read });
|
||
else this.lastTabStorage.delete(page);
|
||
return read;
|
||
}
|
||
|
||
/**
|
||
* Restore browser state into the current context: cookies, pages, storage.
|
||
* Navigates to saved URLs, restores storage, wires page events.
|
||
* Failures on individual pages are swallowed — partial restore is better than none.
|
||
*/
|
||
async restoreState(state: BrowserState): Promise<void> {
|
||
if (!this.context) throw new Error('Browser not launched');
|
||
|
||
// Restore cookies
|
||
if (state.cookies.length > 0) {
|
||
await this.context.addCookies(state.cookies);
|
||
}
|
||
|
||
// Clear stale ownership — the old tab IDs are gone. We'll re-add per-tab
|
||
// owners below as each saved tab gets a fresh ID. Without this reset, old
|
||
// tabId → clientId entries would linger and match new tabs with the same
|
||
// sequential IDs, silently granting ownership to the wrong clients.
|
||
this.tabOwnership.clear();
|
||
|
||
// Re-create pages
|
||
let activeId: number | null = null;
|
||
for (const saved of state.pages) {
|
||
const page = await this.context.newPage();
|
||
const id = this.nextTabId++;
|
||
this.pages.set(id, page);
|
||
const newSession = new TabSession(page);
|
||
this.tabSessions.set(id, newSession);
|
||
this.wirePageEvents(page);
|
||
if (saved.tabId !== undefined) this.restoredTabIds.set(saved.tabId, id);
|
||
|
||
// Restore tab ownership for the new ID — preserves scoped-agent isolation
|
||
// across context recreation (viewport --scale, user-agent change, handoff).
|
||
if (saved.owner) {
|
||
this.tabOwnership.set(id, saved.owner);
|
||
}
|
||
if (saved.device) {
|
||
await applyDevice(newSession, saved.device).catch((err: any) => console.warn(`[browse] Failed to re-apply device for tab ${id}: ${err.message}`));
|
||
}
|
||
|
||
if (saved.loadedHtml) {
|
||
// Replay load-html content via setTabContent — this rehydrates
|
||
// TabSession.loadedHtml so the next saveState sees it. page.setContent()
|
||
// alone would restore the DOM but lose the replay metadata.
|
||
try {
|
||
await newSession.setTabContent(saved.loadedHtml, { waitUntil: saved.loadedHtmlWaitUntil });
|
||
} catch (err: any) {
|
||
console.warn(`[browse] Failed to replay loadedHtml for tab ${id}: ${err.message}`);
|
||
}
|
||
} else if (saved.url) {
|
||
// Validate the saved URL before navigating — the state file is user-writable and
|
||
// a tampered URL could navigate to cloud metadata endpoints. Use the normalized
|
||
// return value so file:// forms get consistent treatment with live goto.
|
||
let normalizedUrl: string;
|
||
try {
|
||
normalizedUrl = await validateNavigationUrl(saved.url);
|
||
} catch (err: any) {
|
||
console.warn(`[browse] Skipping invalid URL in state file: ${saved.url} — ${err.message}`);
|
||
continue;
|
||
}
|
||
await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
||
}
|
||
|
||
if (saved.storage) {
|
||
try {
|
||
await page.evaluate((s: { localStorage: Record<string, string>; sessionStorage: Record<string, string> }) => {
|
||
if (s.localStorage) {
|
||
for (const [k, v] of Object.entries(s.localStorage)) {
|
||
localStorage.setItem(k, v);
|
||
}
|
||
}
|
||
if (s.sessionStorage) {
|
||
for (const [k, v] of Object.entries(s.sessionStorage)) {
|
||
sessionStorage.setItem(k, v);
|
||
}
|
||
}
|
||
}, saved.storage);
|
||
} catch {}
|
||
}
|
||
|
||
if (saved.isActive) activeId = id;
|
||
}
|
||
|
||
// If no pages were saved, create a blank one
|
||
if (this.pages.size === 0) await this.newTab();
|
||
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
||
const scope = this.tabScope.getStore();
|
||
if (scope && !this.pages.has(this.resolveScopeTab(scope))) scope.tabId = scope.picked = this.activeTabId;
|
||
|
||
// Clear refs — pages are new, locators are stale
|
||
this.clearRefs();
|
||
}
|
||
|
||
/**
|
||
* Recreate the browser context to apply user agent changes.
|
||
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
|
||
* Falls back to a clean slate on any failure.
|
||
*/
|
||
async recreateContext(): Promise<string | null> {
|
||
if (this.connectionMode === 'headed') {
|
||
throw new Error('Cannot recreate context in headed mode. Use disconnect first.');
|
||
}
|
||
if (this.extensionContext) {
|
||
throw new Error('Cannot recreate context while BROWSE_EXTENSIONS_DIR extensions are loaded (persistent context). Restart browse with the new settings instead.');
|
||
}
|
||
if (!this.browser || !this.context) {
|
||
throw new Error('Browser not launched');
|
||
}
|
||
|
||
try {
|
||
// 1. Save state
|
||
const state = await this.saveState();
|
||
|
||
// 2. Close old pages and context
|
||
for (const page of this.pages.values()) {
|
||
await page.close().catch(() => {});
|
||
}
|
||
this.pages.clear();
|
||
this.tabSessions.clear();
|
||
await this.context.close().catch(() => {});
|
||
|
||
// 3. Create new context with updated settings
|
||
const contextOptions: BrowserContextOptions = {
|
||
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
|
||
deviceScaleFactor: this.deviceScaleFactor,
|
||
};
|
||
if (this.customUserAgent) {
|
||
contextOptions.userAgent = this.customUserAgent;
|
||
}
|
||
this.context = await this.browser.newContext(contextOptions);
|
||
|
||
// Re-apply stealth: newContext() is a fresh context with no init scripts,
|
||
// so a useragent / viewport --scale rebuild would otherwise drop the
|
||
// webdriver mask, window.chrome.* shape, hardware spoof, and cdc/
|
||
// Permissions cleanup on every restored page. Must run before
|
||
// restoreState() navigates the restored tabs.
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(this.context);
|
||
|
||
if (Object.keys(this.extraHeaders).length > 0) {
|
||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||
}
|
||
|
||
// 4. Restore state
|
||
await this.restoreState(state);
|
||
|
||
return null; // success
|
||
} catch (err: unknown) {
|
||
// Fallback: create a clean context + blank tab
|
||
try {
|
||
this.pages.clear();
|
||
this.tabSessions.clear();
|
||
if (this.context) await this.context.close().catch(() => {});
|
||
|
||
const contextOptions: BrowserContextOptions = {
|
||
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
|
||
deviceScaleFactor: this.deviceScaleFactor,
|
||
};
|
||
if (this.customUserAgent) {
|
||
contextOptions.userAgent = this.customUserAgent;
|
||
}
|
||
this.context = await this.browser!.newContext(contextOptions);
|
||
// Stealth applies to the fallback blank context too.
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(this.context);
|
||
await this.newTab();
|
||
this.clearRefs();
|
||
} catch {
|
||
// If even the fallback fails, we're in trouble — but browser is still alive
|
||
}
|
||
return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Change deviceScaleFactor + viewport size atomically.
|
||
*
|
||
* deviceScaleFactor is a context-level option, so Playwright requires a full context
|
||
* recreation. This method validates the input, stores the new values, calls
|
||
* recreateContext(), and rolls back the fields on failure so a bad call doesn't
|
||
* leave the manager in an inconsistent state.
|
||
*
|
||
* Returns null on success, or an error string if the new context couldn't be built
|
||
* (state may have been lost, per recreateContext's fallback behavior).
|
||
*/
|
||
async setDeviceScaleFactor(scale: number, width: number, height: number): Promise<string | null> {
|
||
if (!Number.isFinite(scale)) {
|
||
throw new Error(`viewport --scale: value must be a finite number, got ${scale}`);
|
||
}
|
||
if (scale < 1 || scale > 3) {
|
||
throw new Error(`viewport --scale: value must be between 1 and 3 (gstack policy cap), got ${scale}`);
|
||
}
|
||
if (this.connectionMode === 'headed') {
|
||
throw new Error('viewport --scale is not supported in headed mode — scale is controlled by the real browser window.');
|
||
}
|
||
|
||
const prevScale = this.deviceScaleFactor;
|
||
const prevViewport = { ...this.currentViewport };
|
||
this.deviceScaleFactor = scale;
|
||
this.currentViewport = { width, height };
|
||
|
||
const err = await this.recreateContext();
|
||
if (err !== null) {
|
||
// recreateContext's fallback path built a blank context using the NEW scale +
|
||
// viewport (the fields we just set). Rolling the fields back without a second
|
||
// recreate would leave the live context at new-scale while state says old-scale.
|
||
// Roll back fields FIRST, then force a second recreate against the old values
|
||
// so live state matches tracked state.
|
||
this.deviceScaleFactor = prevScale;
|
||
this.currentViewport = prevViewport;
|
||
const rollbackErr = await this.recreateContext();
|
||
if (rollbackErr !== null) {
|
||
// Second recreate also failed — we're in a clean blank slate via fallback, but
|
||
// with old scale. Return the original error so the caller sees the primary failure.
|
||
return `${err} (rollback also encountered: ${rollbackErr})`;
|
||
}
|
||
return err;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Read current deviceScaleFactor (for tests + debug). */
|
||
getDeviceScaleFactor(): number {
|
||
return this.deviceScaleFactor;
|
||
}
|
||
|
||
/** Read current tracked viewport (for tests + `viewport --scale` size fallback). */
|
||
getCurrentViewport(): { width: number; height: number } {
|
||
return { ...this.currentViewport };
|
||
}
|
||
|
||
// ─── Handoff: Headless → Headed ─────────────────────────────
|
||
/**
|
||
* Hand off browser control to the user by relaunching in headed mode.
|
||
*
|
||
* Flow (launch-first-close-second for safe rollback):
|
||
* 1. Save state from current headless browser
|
||
* 2. Launch NEW headed browser
|
||
* 3. Restore state into new browser
|
||
* 4. Close OLD headless browser
|
||
* If step 2 fails → return error, headless browser untouched
|
||
*/
|
||
async handoff(message: string): Promise<string> {
|
||
if (this.handoffPending) return this.handoffPending;
|
||
this.handoffPending = this.promoteToHeaded(message);
|
||
try { return await this.handoffPending; }
|
||
finally { this.handoffPending = null; }
|
||
}
|
||
|
||
private async promoteToHeaded(message: string): Promise<string> {
|
||
if (this.closing) return 'ERROR: Browser is shutting down';
|
||
if (this.connectionMode === 'headed' || this.isHeaded) {
|
||
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
|
||
}
|
||
if (!this.browser || !this.context) {
|
||
throw new Error('Browser not launched');
|
||
}
|
||
|
||
// 1. Save state from current browser
|
||
const state = await this.saveState();
|
||
if (this.closing) return 'ERROR: Browser is shutting down';
|
||
const currentUrl = this.getCurrentUrl();
|
||
const previousDisplay = this.xvfb;
|
||
|
||
// 2. Launch new headed browser with extension (same as launchHeaded)
|
||
// Uses launchPersistentContext so the extension auto-loads.
|
||
let newContext: BrowserContext;
|
||
try {
|
||
await this.ensureHeadedDisplay();
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const extensionPath = this.findExtensionPath();
|
||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||
// Same blink-level stealth flags as launch()/launchHeaded(). Without
|
||
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
|
||
// tell that the other two paths strip.
|
||
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs(), ...this.proxyLaunchArgs()];
|
||
if (extensionPath) {
|
||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||
// Auth token is served via POST /extension-token (pinned-origin
|
||
// bootstrap, no file write needed). /health is liveness-only.
|
||
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
|
||
} else {
|
||
console.log('[browse] Handoff: extension not found — headed mode without side panel');
|
||
}
|
||
|
||
// Same profile resolution + singleton-lock cleanup as launchHeaded().
|
||
// This path previously hardcoded ~/.gstack/chromium-profile, silently
|
||
// ignoring $CHROMIUM_PROFILE / $GSTACK_HOME and skipping the lock
|
||
// cleanup — the third shipped drift between the three launch paths.
|
||
const userDataDir = resolveChromiumProfile();
|
||
ensureProjectProfile(userDataDir);
|
||
fs.mkdirSync(userDataDir, { recursive: true });
|
||
cleanSingletonLocks(userDataDir);
|
||
|
||
// Self-heal probe (#2242): handoff always launches the Playwright-cache
|
||
// bundle (this launchPersistentContext call passes no executablePath),
|
||
// so a bundle poisoned by the old in-place rebrand would GPU-crash here
|
||
// exactly like launchHeaded(). Same probe, same contract: a
|
||
// GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed
|
||
// error surfaces through the outer catch as the actionable
|
||
// "Cannot open headed browser" message, headless browser untouched.
|
||
try {
|
||
probePoisonedChromiumBundle(chromium.executablePath());
|
||
} catch (err: unknown) {
|
||
if (err instanceof PoisonedBundleError) throw err;
|
||
// Probe failures (no bundle yet, EACCES) fall through to launch.
|
||
}
|
||
|
||
// T1: same automation-tell-stripping defaults as launchHeaded().
|
||
// The handoff path (headless → headed re-launch) takes the same
|
||
// anti-detection posture.
|
||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||
// XProtect self-heal wrapper (P0 #2554): handoff always launches the
|
||
// Playwright-cache bundle (no executablePath), so the heal applies
|
||
// exactly as in launch()/launchHeaded().
|
||
newContext = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||
headless: false,
|
||
...(this.xvfb ? { env: { ...process.env, DISPLAY: this.xvfb.display } } : {}),
|
||
// #2220: daemon owns signal policy — see launch() for the rationale.
|
||
handleSIGINT: false,
|
||
handleSIGTERM: false,
|
||
handleSIGHUP: false,
|
||
// Match the sandbox policy used by launchHeaded() / launch(). The
|
||
// handoff path is the headless→headed re-launch and shares the same
|
||
// anti-detection posture, including no spurious --no-sandbox infobar.
|
||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||
args: launchArgs,
|
||
viewport: null,
|
||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||
timeout: 15000,
|
||
}));
|
||
if (this.closing) {
|
||
await newContext.close().catch(() => {});
|
||
throw new Error('Browser is shutting down');
|
||
}
|
||
} catch (err: unknown) {
|
||
if (!previousDisplay) this.closeOwnedDisplay();
|
||
if (this.closing) return 'ERROR: Browser is shutting down';
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
||
}
|
||
|
||
const previous = {
|
||
browser: this.browser, context: this.context,
|
||
pages: this.pages, tabSessions: this.tabSessions, tabOwnership: this.tabOwnership,
|
||
activeTabId: this.activeTabId, nextTabId: this.nextTabId,
|
||
connectionMode: this.connectionMode, isHeaded: this.isHeaded,
|
||
dialogAutoAccept: this.dialogAutoAccept, intentionalDisconnect: this.intentionalDisconnect,
|
||
chromiumProcInfo: this.chromiumProcInfo,
|
||
tabGuardrailSoftHit: this.tabGuardrailSoftHit, tabGuardrailHardHit: this.tabGuardrailHardHit,
|
||
};
|
||
this.handoffPrevious = previous;
|
||
try {
|
||
this.context = newContext;
|
||
this.browser = newContext.browser();
|
||
this.pages = new Map();
|
||
this.tabSessions = new Map();
|
||
this.tabOwnership = new Map();
|
||
|
||
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
||
// restoreState() navigates so the init scripts apply to the restored
|
||
// pages — without this the handed-off browser had cmdline args but no
|
||
// JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
|
||
const { applyStealth } = await import('./stealth');
|
||
await applyStealth(newContext);
|
||
|
||
if (Object.keys(this.extraHeaders).length > 0) {
|
||
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
||
}
|
||
|
||
await this.restoreState(state);
|
||
if (this.closing) throw new Error('Browser is shutting down');
|
||
|
||
if (this.browser) {
|
||
const browserRef = this.browser;
|
||
this.onBrowserDisconnected(this.browser, () => {
|
||
if (this.intentionalDisconnect) return;
|
||
void handleChromiumDisconnect(browserRef);
|
||
});
|
||
}
|
||
|
||
this.connectionMode = 'headed';
|
||
this.isHeaded = true;
|
||
this.dialogAutoAccept = false; // User controls dialogs in headed mode
|
||
this.chromiumProcInfo = null;
|
||
try { this.onHeadedPromotion?.(); }
|
||
catch (err) { console.warn('[browse] Headed promotion callback failed:', err); }
|
||
|
||
// 4. Close old headless browser (fire-and-forget)
|
||
this.detachDisconnectHandlers(previous.browser);
|
||
previous.browser.close().catch(() => {});
|
||
|
||
return [
|
||
`HANDOFF: Browser opened at ${currentUrl}`,
|
||
...(this.xvfb ? ['DISPLAY: Off-screen Xvfb; a separate remote desktop is required for human interaction.'] : []),
|
||
`MESSAGE: ${message}`,
|
||
`STATUS: Waiting for user. Run 'resume' when done.`,
|
||
].join('\n');
|
||
} catch (err: unknown) {
|
||
await newContext.close().catch(() => {});
|
||
if (!this.closing) {
|
||
Object.assign(this, previous);
|
||
this.recheckTabGuardrailsOnClose();
|
||
}
|
||
if (!previousDisplay) this.closeOwnedDisplay();
|
||
if (this.closing) return 'ERROR: Browser is shutting down';
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
|
||
} finally {
|
||
this.handoffPrevious = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resume AI control after user handoff.
|
||
* Clears stale refs and resets failure counter.
|
||
* The meta-command handler calls handleSnapshot() after this.
|
||
*/
|
||
resume(): void {
|
||
// Clear refs and frame on the active session
|
||
try {
|
||
const session = this.getActiveSession();
|
||
session.clearRefs();
|
||
session.setFrame(null);
|
||
} catch {}
|
||
this.resetFailures();
|
||
}
|
||
|
||
getIsHeaded(): boolean {
|
||
return this.isHeaded;
|
||
}
|
||
|
||
// ─── Auto-handoff Hint (consecutive failure tracking) ───────
|
||
incrementFailures(): void {
|
||
this.consecutiveFailures++;
|
||
}
|
||
|
||
resetFailures(): void {
|
||
this.consecutiveFailures = 0;
|
||
}
|
||
|
||
getFailureHint(): string | null {
|
||
if (this.consecutiveFailures >= 3 && !this.isHeaded) {
|
||
return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Run a command and surface any navigation the guard blocked while it ran.
|
||
* The block reason replaces the command's own result or error (often
|
||
* "navigation interrupted"), so the caller learns why the tab is blank.
|
||
*/
|
||
async failIfNavigationBlocked<T>(page: Page, work: Promise<T>): Promise<T> {
|
||
const outcome = await work.then((value) => ({ value }), (error) => ({ error }));
|
||
const guard = this.navigationGuards.get(page);
|
||
while (guard && guard.pending.size > 0) await Promise.all([...guard.pending]);
|
||
const blocked = guard?.blocked ?? null;
|
||
if (guard) guard.blocked = null;
|
||
if (blocked) throw new Error(blocked);
|
||
if ('error' in outcome) throw outcome.error;
|
||
return outcome.value;
|
||
}
|
||
|
||
/**
|
||
* D2: validateNavigationUrl only sees explicit navigations. Redirect hops and
|
||
* page-driven navigations (links, scripts, forms, frames) arrive here as
|
||
* navigation requests; one that targets a blocked address blanks the tab and
|
||
* is reported by failIfNavigationBlocked.
|
||
*/
|
||
private guardNavigations(page: Page): void {
|
||
if (this.navigationGuards.has(page)) return;
|
||
const guard = { pending: new Set<Promise<void>>(), blocked: null as string | null };
|
||
this.navigationGuards.set(page, guard);
|
||
page.on('request', (req) => {
|
||
if (!req.isNavigationRequest()) return;
|
||
const check: Promise<void> = blockedNavigationReason(req.url()).then(async (reason) => {
|
||
if (!reason || page.isClosed()) return;
|
||
guard.blocked = reason;
|
||
console.warn(`[browse] ${reason} (navigation from ${req.frame().url() || 'a new page'}; tab reset to about:blank)`);
|
||
// A target that fails fast commits its error page around the reset, so
|
||
// let it settle (bounded; a hanging target is cut off by the reset),
|
||
// then blank the tab until it stays blank (about:blank loads at once).
|
||
await Promise.race([req.response().catch(() => null), new Promise((resolve) => setTimeout(resolve, 1_000))]);
|
||
// Leaving an error page can commit a second about:blank after the reset
|
||
// resolves; wait for the main frame to stay quiet so the next command's
|
||
// navigation is not interrupted by it.
|
||
for (let attempt = 0; attempt < 3 && !page.isClosed(); attempt++) {
|
||
await page.goto('about:blank', { waitUntil: 'load', timeout: 5_000 }).catch(() => {});
|
||
const late = await page.waitForEvent('framenavigated', { predicate: (frame) => frame === page.mainFrame(), timeout: 250 })
|
||
.then(() => true, () => false);
|
||
if (!late && page.url() === 'about:blank') break;
|
||
}
|
||
}).finally(() => { guard.pending.delete(check); });
|
||
guard.pending.add(check);
|
||
});
|
||
}
|
||
|
||
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
|
||
private wirePageEvents(page: Page) {
|
||
const pages = this.pages;
|
||
const tabSessions = this.tabSessions;
|
||
this.guardNavigations(page);
|
||
// Track tab close — remove from pages and sessions maps, switch to another tab
|
||
page.on('close', () => {
|
||
for (const [id, p] of pages) {
|
||
if (p === page) {
|
||
pages.delete(id);
|
||
tabSessions.delete(id);
|
||
console.log(`[browse] Tab closed (id=${id}, remaining=${pages.size})`);
|
||
// If the closed tab was active, switch to another
|
||
const remaining = [...pages.keys()];
|
||
const fallback = remaining.length > 0 ? remaining[remaining.length - 1]! : 0;
|
||
if (pages === this.pages) {
|
||
if (this.activeTabId === id) this.activeTabId = fallback;
|
||
} else if (this.handoffPrevious?.pages === pages && this.handoffPrevious.activeTabId === id) {
|
||
this.handoffPrevious.activeTabId = fallback;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (pages === this.pages) this.recheckTabGuardrailsOnClose();
|
||
});
|
||
|
||
// Clear ref map on navigation — refs point to stale elements after page change
|
||
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
||
page.on('framenavigated', (frame) => {
|
||
if (frame === page.mainFrame()) {
|
||
// Find the TabSession for this page and clear its per-tab state
|
||
for (const session of tabSessions.values()) {
|
||
if (session.page === page) {
|
||
session.onMainFrameNavigated();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
||
page.on('dialog', async (dialog) => {
|
||
const entry: DialogEntry = {
|
||
timestamp: Date.now(),
|
||
type: dialog.type(),
|
||
message: dialog.message(),
|
||
defaultValue: dialog.defaultValue() || undefined,
|
||
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
||
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
||
};
|
||
addDialogEntry(entry);
|
||
|
||
try {
|
||
if (this.dialogAutoAccept) {
|
||
await dialog.accept(this.dialogPromptText ?? undefined);
|
||
} else {
|
||
await dialog.dismiss();
|
||
}
|
||
} catch {
|
||
// Dialog may have been dismissed by navigation
|
||
}
|
||
});
|
||
|
||
page.on('console', (msg) => {
|
||
addConsoleEntry({
|
||
timestamp: Date.now(),
|
||
level: msg.type(),
|
||
text: msg.text(),
|
||
});
|
||
});
|
||
|
||
page.on('request', (req) => {
|
||
addNetworkEntry({
|
||
timestamp: Date.now(),
|
||
method: req.method(),
|
||
url: req.url(),
|
||
});
|
||
});
|
||
|
||
page.on('response', (res) => {
|
||
// Find matching request entry and update it (backward scan)
|
||
const url = res.url();
|
||
const status = res.status();
|
||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||
const entry = networkBuffer.get(i);
|
||
if (entry && entry.url === url && !entry.status) {
|
||
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Capture response sizes via requestfinished — but DO NOT call
|
||
// response.body() here. Pre-fix, this listener materialized every
|
||
// response body across CDP just to read .length: multi-GB/hour of
|
||
// Buffer churn on long-lived headed Chromium with media-heavy
|
||
// pages, the primary Bun-side accelerant on the gbrowser-OOM
|
||
// investigation. req.sizes() pulls from the Network.loadingFinished
|
||
// event Chromium already emits — accurate for chunked transfer,
|
||
// gzip-compressed responses, and streaming media, all the cases
|
||
// where the previous Content-Length-header approach would have
|
||
// missed the size.
|
||
//
|
||
// The "single context-level CDP listener" architecture (D10's
|
||
// stretch goal — would reduce per-page listener count from N to 1
|
||
// via Target.setAutoAttach) is deferred. TODOS.md tracks it.
|
||
page.on('requestfinished', async (req) => {
|
||
try {
|
||
const sizes = await req.sizes().catch(() => null);
|
||
if (!sizes) return;
|
||
const url = req.url();
|
||
const size = sizes.responseBodySize ?? 0;
|
||
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
||
const entry = networkBuffer.get(i);
|
||
if (entry && entry.url === url && !entry.size) {
|
||
networkBuffer.set(i, { ...entry, size });
|
||
break;
|
||
}
|
||
}
|
||
} catch {
|
||
// Best-effort: requestfinished fires for aborted/cached requests too,
|
||
// where sizes() is unavailable. Missing size is acceptable; an
|
||
// unbounded throw would noise the console for every cache hit.
|
||
}
|
||
});
|
||
}
|
||
}
|