Files
gstack/BROWSER.md
T
28d59ad56c v1.68.0.0 fix: next tracker wave — 16 verified fixes in, 90 stale PRs and 21 issues closed with receipts (#2632)
* fix(plan-tune): reject never-ask on one-way ids at --write

--check already ignored those prefs; --write still stored them and
--stats counted them as a working NEVER_ASK. Refuse the write and
count leftover on-disk prefs as INERT_ONE_WAY.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: gstack-config get returns "" with exit 0 for keys that have no default

Skill preambles read configuration with

    VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")

and that fallback only fires on a non-zero exit. lookup_default ended in a
catch-all that echoed "" and returned 0, so for any key missing from the table
VAR came back empty and the default written right there in the preamble was
unreachable. The skill then branched on a value it never specified: "skip
entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="".

Four keys that skills actually read had no entry and took that path:

    question_tuning         -> callers assume "false"
    repo_mode               -> callers assume "unknown"
    team_mode               -> callers assume "false"
    transcript_ingest_mode  -> callers assume "off"

Each default above is the value the call sites already substitute in their own
`|| echo` fallback, so this only makes reachable what was already intended.

The catch-all now returns non-zero. That is deliberately scoped to the
unknown-key arm alone: keys whose default is intentionally empty still exit 0,
because "" is their real answer and their callers depend on it --
cross_project_learnings ("unset triggers the first-time prompt"),
redact_repo_visibility ("empty falls through to gh/glab detection"),
salience_allowlist, user_slug_at_*. Making every empty answer an error would
have broken those.

test/gstack-config-defaults.test.ts pins the class rather than the four
instances: it parses the case arms and asserts every `gstack-config get <key>`
site in the tree is covered, so adding a read without a default fails CI. It
also pins the exit-code contract in both directions. Verified failing against
the pre-fix script, where it names exactly those four keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(redact): a typo'd subcommand no longer exits 0 having done nothing

main() recognised exactly two subcommands and let everything else fall through
to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so:

    $ gstack-redact install-prepush-hooks    # plural typo
    gstack-redact scan — repo UNKNOWN
      (no findings)
    $ echo $?
    0

No hook was installed, and the operator has every reason to believe the
credential guard is armed. A guard that silently no-ops must never exit 0.

Two smaller faults in the same dispatch, both of which lead people here:

- There was no --help handler, so `gstack-redact --help` fell through to the
  scanner. Piping a credential to it scanned the secret and exited 3.
- With no piped input and no --from-file, readInput() blocks on readSync(fd 0)
  until an EOF that an interactive terminal never sends. That prints nothing
  at all, so it reads as a hang rather than as "this is a filter, feed it".

Now: --help/-h/help prints usage and exits 0; an unrecognised positional
prints the offender and exits 1; a TTY with nothing piped in prints usage
instead of blocking. "scan" stays accepted, because the human output header
reads "gstack-redact scan — repo …" and that is what people type.

Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH
findings and callers gate dispatch on them, so a usage error exiting 2 would
be read as "medium findings — prompt the user". A test pins that.

Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(browse): one ambiguous ref no longer kills the whole annotated screenshot

`snapshot -a` exits 1 with "Selector matched multiple elements" on most real
pages, so /qa, /canary and /land-and-deploy silently produce reports whose
screenshots do not exist. Plain `screenshot <path>` is unaffected.

Refs are built as getByRole(role, {name}) and disambiguated with .nth() when
role+name repeats. That disambiguation cannot fire for a node with NO accessible
name: the locator degrades to getByRole(role) with no name filter, and the count
driving .nth() is taken from the FILTERED aria snapshot while getByRole matches
the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed
paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are
correctly unnamed per ARIA, so this is the common case rather than an edge case.

boundingBox() then hits Playwright strict mode, and the catch allowlisted only
timeout/closed/Target/Execution-context messages — so the strict-mode error was
re-thrown and aborted every remaining annotation.

Two changes:

- `.first()` before boundingBox(), so an ambiguous ref draws a box on its first
  match instead of aborting. The heatmap path below has always tolerated this via
  a bare `catch {}`; annotate was the only path that could be killed outright.
- the catch no longer re-throws on unrecognised messages. A box we cannot measure
  is a box we do not draw, never a reason to lose the rest of the page. Set
  BROWSE_DEBUG to see what was skipped.

Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which
reads as "screenshots are broken" rather than "you forgot a flag". It now warns
and points at `browse screenshot <path>`.

Verified by rebuilding both ways against the same page with 51 refs present:
  before — "Selector matched multiple elements", no file written
  after  — exit 0, 229KB PNG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json

repair now fails with exit 2 when the VERSION file is absent or empty
instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and
regressed package.json below where it started. classify gains an additive
versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated
one. Re-derived from PR #2612 under the generated-file screening rule.

Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531).
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses

probeMode previously stat'd every walked file, so setup-gbrain gated its
silent bulk ingest on pre-filter counts that the write path would never
ingest (#2394). The attribution decision now lives in ONE shared gate
(sessionIsAttributable — cheap-parse: cwd extraction + memoized
resolveGitRemote, never a full page build) used by BOTH probeMode and
preparePages, so the two stages' post-attribution counts are structurally
identical. ProbeReport gains skipped_unattributed; the probe prints what it
excluded and --include-unattributed restores raw counts. The parity is
pinned at the prepare stage (probe post-attribution == transcripts reaching
import), deliberately NOT == final written.

Re-derived from PR #2612 under the generated-file screening rule; the
shared-gate design and the remote memo are additions from the plan review.

Fixes #2394.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): allow CPU and network throttling for performance measurement

Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to
CDP_ALLOWLIST.

Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only
machine available was a fast developer workstation. Client-side processing
measured 1.4s where the user experienced minutes, so the conclusion had to be
reached arithmetically rather than observed. Throttling would have let the
measurement reproduce the reporter's conditions directly.

Both fit the existing posture rather than widening it:
  - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride
    and setUserAgentOverride, which are equally mutating and scoped to the tab.
  - Neither method reads page content. setCPUThrottlingRate affects only timing;
    emulateNetworkConditions constrains traffic rather than inspecting it, so no
    request bodies, headers or cookies are exposed. Both are output: 'trusted'
    because they return no page-derived data.

scope 'tab' for both, matching the surrounding Emulation entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613)

echo $$ inside the backgrounded subshell recorded the PARENT hook's PID —
which exits immediately — so every subsequent session judged the lock stale
and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each
other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS
bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell).

Staleness is now two independent detectors: PID liveness (as before, but
against the real holder), and a 30-minute hard TTL on the heartbeat mtime —
reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge
the lock forever. The holder touches the pidfile after the pull and after
setup, so a legitimately-slow run keeps itself alive. Empty and missing
pidfiles are respected inside the TTL window (the mkdir→echo race) and
reclaimed past it.

Fixes #2613.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual)

Bun.spawn sites were structurally outside the windowsHide census (it swept
child_process bindings only). The runtime was already safe — native Bun hides
consoles by default and bun-polyfill.cjs defaults windowsHide !== false since
#2523/#2539 — but implicit defaults are exactly what regress silently. Every
Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on
unix-only sites like Xvfb/xattr/open), and a second SWEEP in
windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516)

The daily pull refreshed only ~/.gstack itself, never the detached worktree
at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the
brain served stale pages forever unless setup-gbrain/sync-gbrain happened to
run. brain-sync --once now advances the worktree once per 24h behind an
ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance
warns once a day, not at every skill boundary), inside the existing run lock
and before any ingest step touches the worktree.

The new gstack-gbrain-source-wireup --advance-only is built for the
unattended cadence: git-only (no gbrain prereqs), pins every operation to the
managed worktree (refuses paths that are not worktrees of the artifacts
repo), refuses dirty worktrees, and never runs the force-remove recovery — a
cron path must not be able to delete local changes. A static pin keeps the
force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence.

Fixes #2516.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392)

Transcript ingest now respects the same trust store as code import — the gate
existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily
ingested transcripts from deny-listed repos. preparePages filters prepared
transcript pages through ONE batch policy lookup (new 'get --batch' verb on
bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client
adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora
never pay a 10s-timeout subprocess per remote).

Outcomes match code-import semantics: read-only → clean skip
(skipped_policy_readonly), deny → counted refusal (skipped_policy_deny),
corrupted/unreadable store → HARD ERROR before any write (state, staging,
egress receipt, and import all untouched) with the recovery command named —
policy corruption must never read as successful ingestion. Artifacts are
never policy-filtered (their git_remote is a project slug, not a remote).

Fixes #2392.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up)

The ported defaults table synthesized repo_mode → "unknown", but EMPTY is
load-bearing for that key: gstack-repo-mode treats any non-empty answer as a
user override and skips its own repo classification — the synthesized default
turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by
test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol).
repo_mode joins the empty-is-real carve-outs (empty output, exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): consent before killing a healthy headless daemon

The pair-agent headed switch spawned 'connect --force-restart'
unconditionally — auto-killing a live headless daemon (open tabs, cookies,
logins) in direct contradiction of the iron rule it sits beside ('only an
explicit --force-restart may kill a live daemon'). The CLI now captures
daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon)
and relaunches only when the user passed --force-restart to pair-agent;
otherwise it prints the tab count and continues against the existing daemon.
The /pair-agent skill gains a matching one-way-door consent question
(template half rides the wave's template block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope

hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json,
so one project's remote gbrain registration reclassified broken local engines
as thin-client machine-wide. It now reads user scope plus only the cwd's
nearest-ancestor project key.

The precedence itself was verified empirically and hermetically (fake HOME +
CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining
gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL
WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint
resolution flips to nearest-ancestor-project-first, and the stale user-first
pin in brain-cache-roundtrip now pins the verified precedence. (The user-first
jq in the brain-sync preamble resolver gets the same swap in the template
block.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug)

Found live during this wave's CEO review: bin/gstack-slug emitted
SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly
gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were
filing into the wrong project store (observed polluting Context Recovery with
another repo's decisions). Root cause: a stray empty ~/.git directory made
the walk-up crown $HOME as the outermost project root; the remote lookup ran
only against that root, failed silently, and the basename fallback cached
'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo
ancestor triggered it.

Fix: the walk now finds the outermost ancestor whose .git actually resolves
an origin remote and derives owner-repo with remote-slug's byte-identical
parse; marker-only ancestors keep anchoring the basename fallback but can no
longer shadow a real remote. A new cache self-heal recomputes the poisoned
shape (cached == basename of a marker root while a remote-bearing repo exists
below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and
non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned
by a 10-case parity suite. Store migration for pre-fix data is tracked in
TODOS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally

Producers appended lines to .brain-queue.jsonl while the drain re-read and
os.replace'd it; the in-code comment admitted a lockless append between the
re-read and the replace was lost. Locks and rename-rotation designs were both
reviewed and rejected (each retained a tail race); the shipped design is a
maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic
rename), the drain snapshots filenames, processes, and deletes exactly what
it snapshotted. Writer and drainer never share an inode — nothing to race.

Semantics: at-least-once (a crash between process and unlink re-drains;
downstream content-hash dedup absorbs duplicates); retained (privacy-held)
records keep their files; unparseable records are kept + warned, never
destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain
(crash-leftover .migrating files recovered too); status/drop-queue count both
surfaces; discover-new writes spool records and advances its cursor
per-record-written. The preamble's queue-depth line switches to spool count
in this wave's template block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin-context): native slug fallback walks up like bash gstack-slug

slugFromEnvironment derived the slug from the INNERMOST repo's origin while
bash gstack-slug walks to the outermost project root — nested/vendored repos
split their stores across the bash/native boundary (win32 hits the native
path constantly). The native fallback now ports _outermost_project_root
faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap,
fixed-point termination) plus the full resolution order: env override →
walk-up → sticky cache with the #1125 self-heal → remote get-url → basename.
Twelve mirrored scenarios drive BOTH implementations against the same
fixtures and pin identical slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width

The degraded path counted every remote-tracking ref on every remote — stale
experiment branches and second remotes inflated version allocation, and a
failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote
--heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref
mutation); on failure, local refs/remotes/origin ONLY with an explicit
stale-refs warning; a failed base read zeroes at the LOCAL version file's
width so a 3-digit repo allocates 0.0.1, not 0.0.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): hooks register the global-install path and re-point stale ones

Registering hooks from a dev worktree baked that worktree's absolute path
into settings.json — deleting the worktree left a dead hook erroring on
every session stop, and the presence-only dedup (list-sources | grep) could
never re-point it. setup's hook paths now route through _hook_install_path
(global install preferred, source dir fallback), and the new ensure-event
verb on gstack-settings-hook compares the registered command payload against
canonical: identical → no write, different → single atomic replacement
(never zero or two registrations). The plan-tune hooks had the same stale
pattern and get the same fix without re-triggering their consent prompt.

Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a
SILENT exit 0 — the registrar's write path now catches, prints, and exits 1,
so a failed update can never report fake-green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): learnings capture is unconditional at completion (#2402)

43 of 44 learnings entries came from explicit /learn — the completion-status
prose read 'if you discovered a durable project quirk... log it', which
models treated as optional. The step now ALWAYS runs: review the session for
durable learnings, log each one, and state 'No durable learnings this
session' explicitly when the review comes up empty — an empty result, never
a skipped step. Re-derived from PR #2612 under the generated-file screening
rule.

Fixes #2402.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scrape): untrusted-content warning on the page-fetching skills (#2441)

/scrape and /skillify consumed page content with zero injection guidance —
the CHANGELOG claimed coverage the skills didn't have. The warning now lives
in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts),
embedded in the browse COMMAND_REFERENCE as before AND injected standalone
into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single
source, wording can never drift between surfaces. Re-derived from PR #2612
under the generated-file screening rule. (Structural isolation for
skillify-generated code is tracked as its own TODO.)

Fixes #2441.
Contributed by @Lockyer228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): checklist paths resolve from the installed skill root (#2518)

/review Step 2 read .claude/skills/review/checklist.md — a path relative to
the TARGET repo, which only resolves in gstack's own checkout. Every
checklist/greptile-triage/TODOS-format reference (six across five templates —
two more than the issue named, same class) now uses the installed-root form
~/.claude/skills/gstack/review/... that the templates' other references
already use. The install-root class itself (non-default install dirs) is
#1882, deliberately its own PR.

Fixes #2518.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): one-way-door consent question before a daemon relaunch (template half)

The skill flow now checks daemon liveness before Step 4 and asks an explicit
one-way-door question (tabs/cookies/logins are lost) before passing
--force-restart — never proceeding on a vague reply. Pairs with the CLI-half
commit that stopped pair-agent auto-killing live daemons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(codex): resume does not amortize the ~21K session prelude (#2387)

Measured (#2387): every codex exec call pays Codex's session prelude, and a
resumed call came in slightly ABOVE a fresh one — resume buys continuity,
never token savings. The skill now says so where the resume flow lives:
prefer one codex call per skill, batch questions into it.

Fixes #2387.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517)

/gstack-upgrade went straight to stash + reset --hard origin/main. Now it
tries git pull --ff-only --autostash first (the same policy session-update's
auto-upgrade uses). The destructive fallback runs unprompted ONLY when both
git status --porcelain AND git rev-list origin/main..HEAD are empty — a
clean tree with unpushed local commits is NOT safe, reset destroys them.
Anything else requires an explicit one-way-door confirmation that lists every
dirty file and unpushed commit being discarded.

Fixes #2517.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first

Two resolver halves deferred from earlier wave commits: the queue-depth line
counts .brain-queue.d/*.json spool records (plus legacy lines until the
drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to
nearest-ancestor-project-first — matching the empirically verified Claude
Code precedence (project-local beats user scope) instead of the backwards
user-first assumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block)

Pure generator output for the six template/resolver commits above (learnings
capture, untrusted-content warning, review paths, pair-agent consent, codex
resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs +
--host codex + --host factory, with the three ship golden fixtures refreshed
per the documented procedure. The three sidecar-path pins in
gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract
(#2518). Restores template freshness; full suite green from here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups

The v1.67 adversarial-review residuals section shrinks to the one item the
wave couldn't reach (iOS tap routing — needs real-device verification). New
entries: skillify structural isolation (a prose warning is not a boundary for
page-derived generated code) and the slug store migration (pre-fix sessions
on stray-marker machines filed data under the degraded slug; post-fix reads
go to the correct store, so history needs a merge/alias).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align cross-cutting pins with the wave's contracts

Three suites pinned pre-wave behavior: browse's gstack-config test asserted
the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths
suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same
invariant via tmp + os.replace, one file per record — pinned in its new
form); and nine carve-guard skeleton ceilings absorbed the #2402
unconditional-learnings prose (~450B per skill), bumped with measured values
per the guard's own protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs

The self-check pinned the review checklist as a class-1 alias-relative ref;
#2518 moved those refs to the installed gstack root (class 2). The guard now
proves the scanner sees them in their new class, so the class-2 assertion
can't go vacuous.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure)

The coverage audit found one regression-shaped gap: nothing pinned that the
upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a
future template edit reverting to reset-first would fail nothing. Pinned:
the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned
the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in
scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened

The ship review army (4 specialists + red-team + checklist, 29 findings)
produced 8 mechanical auto-fixes and 11 decisions; the accepted set:

- win32 slug parity completed: lib/bin-context.ts gains the remote-first
  outermost walk + degraded-cache self-heal the bash side got this wave —
  the two implementations now agree on the stray-marker live-bug shape,
  pinned by shared fixtures (multi-specialist 9/10 finding).
- probe honors the plan's bounded-read decision: 256KB prefix, extraction
  semantics mirrored from parseTranscriptJsonl so probe/prepare can never
  diverge on the same file (>1MB transcript test).
- policy normalize parity: bash normalize() now matches canonicalizeRemote
  on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two
  ways) — a deny for those shapes could previously slip the transcript gate.
- session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches).
- settings-hook: unparseable settings.json errors instead of being replaced
  with {}; ensure-event keys on (event, source) so matcher changes update
  in place — never zero or two registrations.
- dot-only slug guard at both parse sites (hostile 'url = ..' can't escape
  projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock);
  brain-sync .migrating never clobbered; drop-queue/status count .migrating;
  snapshot -o warning correct + surfaced in diff mode; version-bump test
  order-dependence removed; uninstall clears the advance stamp.

Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK
conflation (noted, misdiagnosis-only).

270 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings

Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured
with P1 gate) on the full wave diff. Multi-source findings, all fixed:

- P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY
  when classification proves it staged or dropped; a classifier crash, a
  missing class file, or a malformed pulled .brain-privacy-map.json (which
  previously nuked the whole snapshotted queue, remotely triggerable) now
  retains everything, warns, and re-drains next run. load_privacy_map treats
  corrupt maps as retain-all, never as empty.
- next-version cannot silently drop a live claim: unreadable advertised refs
  get a targeted --depth=1 fetch + retry; still-unreadable claims surface as
  UNKNOWN warnings instead of duplicate-version silence.
- session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder
  can no longer delete the new holder's lock) + a 5-min background heartbeat
  so a legitimately-slow pull/setup is never reclaimed while alive.
- ensure-event collapses ALL same-(event,source) duplicates to one canonical
  entry; unique per-process tmp path; setup call sites surface (not swallow)
  the hardened refusals.
- memory-ingest: --limit counts only policy-permitted pages (denied records
  no longer starve permitted ones); --probe applies the same policy filter as
  --bulk (skipped_policy_* fields on the report).
- version-bump repair accepts a genuine literal 0.0.0.0 VERSION file.
- slug heal restricted to the stray-.git shape — package.json-anchored
  wrapper roots keep their legit sticky identity (#2212 preserved).
- brain-sync: idle fast path sees leftover .migrating records; unparseable
  spool records quarantine instead of warning forever; migration comment
  stops overclaiming the transition-window race.
- CDP throttling justifications document override persistence (callers own
  restoration), pinned in the allowlist test.

Deferred with record: deny retroactivity for already-ingested pages (P2 TODO,
same semantics as the code-import gate); legacy-migration tail race
(transition-window, requires pre-spool writers).

288 pass / 0 fail across the 10 touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix)

Pure generator output for the brain-sync block's jq ancestor match now
accepting backslash-formed Windows project keys — previously project-scoped
brains were invisible on Windows while the TS scope resolvers saw them.
Golden ship fixtures refreshed per the documented procedure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth

The verify re-review passed the P1 gate (0 P1s) and left three residuals,
all applied: transcriptCwdFromPrefix reads in chunks until one complete
record (4MB cap) so a giant first prompt can't truncate mid-JSON and break
probe/bulk parity; partial_pages derives from the FINAL prepared set instead
of the whole scanned corpus; the preamble queue-depth line counts leftover
.brain-queue.jsonl.migrating records like the status path does (regen + goldens included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.68.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.68.0.0

BROWSER.md: fix the $B cdp example (positional JSON params, not --json;
depth is the real CDP param) and add the new perf-throttling examples
(Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with
their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the
state-files table row for the sync queue now names the maildir-style
spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align memory-pipeline probe pins with the #2394 stage-count contract

The paid-tier E2E pinned the pre-fix contract (probe headline = raw
discovered). Probe now counts post-attribution — the same gate --bulk
uses — with an explicit unattributed-skip line. Adds the
--include-unattributed companion pin so all 9 fixtures stay accounted for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl

The targeted-fetch retry for branches whose advertised tip has no local
object ran ONE git fetch per branch (10s cap each). On a shallow clone
against a busy remote that crawls the network for minutes — CI's shard
deadline killed the free suite mid-file. Missing tips now collect into a
single batched shallow fetch (15s cap); refs still missing after the
batch (one unservable ref fails the whole transfer) get a capped
per-branch retry, and anything past the cap warns as an UNKNOWN claim
instead of fetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the batched fetch + make the offline-contract tests hermetic

Two new G2 pins: N unfetched claim branches resolve with exactly ONE
fetch spawn (PATH-shimmed git counts invocations), and one unservable
ref no longer poisons the batch — live claims resolve via the bounded
retry while only the ghost warns UNKNOWN.

The #2545 offline-contract tests now run the CLI in a local fixture repo
instead of the repo's own checkout: the checkout path did a live
ls-remote against the real origin (operator-network-dependent, and the
CI shard-deadline hang). The online-contract test gains a succeeding gh
stub, so fallback:null is asserted deterministically instead of only
when the operator happens to be authed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source

The CI quality gate scans every ADDED diff line with the redact engine,
so the #2610 port's raw fixture literals failed the very gate they
exist to test. The fixture is now assembled at runtime; the scanner
still receives the identical bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence

The hermetic offline-contract fixture had no origin remote, so
detectHost() fell through to auth probes: a machine with glab authed
passed via the gitlab path while a bare CI runner read host:unknown
(offline stays false there) and failed. The fixture now pushes to a
local bare origin at a path containing github.com — the URL sniff pins
host:github identically everywhere, asserted explicitly in both tests,
with every git call still local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: y$un_ <forrest.sun527@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ricky <ricky@kinokostudio.com.hk>
Co-authored-by: Connex Client Access <paul@paulkortman.com>
Co-authored-by: henbima <henbima@gmail.com>
2026-08-19 11:42:55 -07:00

1469 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Browser — Complete Reference
gstack's browser surface in one document. Headless Chromium daemon, ~70+
commands, ref-based element selection, codifiable browser-skills, real-browser
mode with a Chrome side panel, an in-sidebar Claude PTY, an ngrok pair-agent
flow, and a layered prompt-injection defense — all behind a compiled CLI that
prints plain text to stdout. ~100-200ms per call. Zero context-token overhead.
If you've used gstack in the last release or two, the productivity loop is the
new headline: `/scrape <intent>` drives a page once, `/skillify` codifies the
flow into a deterministic Playwright script, and the next `/scrape` on the
same intent runs in ~200ms instead of ~30 seconds of agent re-exploration.
---
## Quick start
```bash
# One-time: build the binary (browse/dist/browse, ~58MB)
bun install && bun run build
# Set $B once and forget about it
B=./browse/dist/browse # or ~/.claude/skills/gstack/browse/dist/browse
# Drive a page
$B goto https://news.ycombinator.com
$B snapshot -i # @e refs you can click/fill/inspect later
$B click @e30 # click ref 30 from the snapshot
$B text # get clean page text
$B screenshot /tmp/hn.png
# Codify a repeated flow
/scrape latest hacker news stories
/skillify # writes ~/.gstack/browser-skills/hn-front/...
/scrape hacker news front page # second call: 200ms via the codified skill
# Watch Claude work in real time
$B connect # headed Chromium + Side Panel extension
```
---
## Table of contents
1. [What it is](#what-it-is)
2. [The productivity loop — `/scrape` + `/skillify`](#the-productivity-loop)
3. [Architecture](#architecture)
4. [Command reference](#command-reference)
5. [Snapshot system + ref-based selection](#snapshot-system)
6. [Browser-skills runtime](#browser-skills-runtime)
7. [Domain-skills (per-site agent notes)](#domain-skills)
8. [Real-browser mode (`$B connect`)](#real-browser-mode) — including [`--headed` + `--proxy` + `--navigate` (v1.28.0.0)](#headed-mode--proxy--browser-native-downloads-v12800)
9. [Side Panel + sidebar agent](#side-panel--sidebar-agent)
10. [Pair-agent — remote agents over an ngrok tunnel](#pair-agent)
11. [Authentication + tokens](#authentication)
12. [Prompt-injection security stack (L1L6)](#security-stack)
13. [Screenshots, PDFs, visual inspection](#screenshots-pdfs-visual)
14. [Local HTML — `goto file://` vs `load-html`](#local-html)
15. [Batch endpoint](#batch-endpoint)
16. [Console, network, dialog capture](#capture)
17. [JS execution — `js` + `eval`](#js-execution)
18. [Tabs, frames, state, watch, inbox](#tabs-frames-state)
19. [CDP escape hatch + CSS inspector](#cdp)
20. [Performance + scale](#performance)
21. [Multi-workspace isolation](#multi-workspace)
22. [Environment variables](#environment-variables)
23. [Source map](#source-map)
24. [Development + testing](#development)
25. [Cross-references](#cross-references)
26. [Acknowledgments](#acknowledgments)
---
## What it is
A compiled CLI binary that talks to a persistent local Chromium daemon over
HTTP. The CLI is a thin client — it reads a state file, sends a command,
prints the response to stdout. The daemon does the real work via
[Playwright](https://playwright.dev/).
Everything that was a Chrome MCP server in the early days now happens through
plain stdout. No JSON-schema framing, no protocol negotiation, no persistent
WebSocket — Claude's Bash tool already exists, so we use it.
Three escalating modes:
- **Headless** (default). Daemon runs Chromium with no visible window. Fastest,
cheapest, what skills like `/qa`, `/design-review`, `/benchmark` use by
default.
- **Headed via `$B connect`**. Same daemon, but Chromium is visible (rebranded
as "GStack Browser") with the Side Panel extension auto-loaded. You watch
every command tick through in real time.
- **Pair-agent over a tunnel**. Daemon binds a second listener that ngrok
forwards. A remote agent (Codex, OpenClaw, Hermes, anything that can speak
HTTP) drives your local browser through a 26-command allowlist with a
scoped, single-use token.
---
## The productivity loop
The shipped headline of v1.19.0.0. Two gstack skills wrap the browser-skills
runtime so the second time you ask Claude to scrape a page, it runs in ~200ms.
### `/scrape <intent>`
One entry point for pulling page data. Three paths under the hood:
1. **Match path (~200ms)** — agent runs `$B skill list`, semantically matches
the intent against each skill's `triggers:` array + `description` + `host`,
and runs `$B skill run <name>` if a confident match exists.
2. **Prototype path (~30s)** — no match, agent drives the page with `$B goto`,
`$B text`, `$B html`, `$B links`, etc., returns the JSON, and appends a
one-line "say `/skillify`" suggestion.
3. **Mutating-intent refusal** — verbs like *submit*, *click*, *fill* route
to `/automate` (Phase 2b, P0 in `TODOS.md`). `/scrape` is read-only by
contract.
### `/skillify`
Codifies the most recent successful `/scrape` prototype into a permanent
browser-skill on disk. Eleven steps, three locked contracts:
- **D1 — Provenance guard.** Walks back ≤10 agent turns for a clearly-bounded
`/scrape` result. Refuses with one specific message if cold. No silent
synthesis from chat fragments.
- **D2 — Synthesis input slice.** Extracts ONLY the final-attempt `$B` calls
that produced the JSON the user accepted, plus the user's intent string.
Drops failed selectors, drops chat, drops earlier-session content.
- **D3 — Atomic write.** Stages everything to `~/.gstack/.tmp/skillify-<spawnId>/`,
runs `$B skill test` against the temp dir, and only renames into the final
tier path on test pass + user approval. Test fail or rejection: `rm -rf` the
temp dir entirely. No half-written skill ever appears in `$B skill list`.
Mutating-flow sibling `/automate` is split out as P0 in `TODOS.md` and ships
on the next branch — same skillify machinery, per-mutating-step confirmation
gate when running non-codified.
See [`docs/designs/BROWSER_SKILLS_V1.md`](docs/designs/BROWSER_SKILLS_V1.md)
for the full design + decision trail.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ $B goto https://staging.myapp.com │
│ │ │
│ ▼ │
│ ┌──────────┐ HTTP POST ┌──────────────┐ │
│ │ browse │ ──────────────── │ Bun HTTP │ │
│ │ CLI │ 127.0.0.1:rand │ daemon │ │
│ │ │ Bearer token │ │ │
│ │ compiled │ ◄────────────── │ Playwright │──── Chromium │
│ │ binary │ plain text │ API calls │ (headless │
│ └──────────┘ └──────────────┘ or headed) │
│ ~1ms startup persistent daemon │
│ auto-starts on first call │
│ auto-stops after 30 min idle │
└─────────────────────────────────────────────────────────────────┘
```
### Daemon lifecycle
1. **First call.** CLI checks `<project>/.gstack/browse.json` for a running
server. None found — it spawns `bun run browse/src/server.ts` in the
background. Daemon launches headless Chromium via Playwright, picks a
random port (1000049151, deliberately below the macOS ephemeral pool
49152-65535 so the OS never hands a colliding port to another process),
generates a bearer token, writes the state file (chmod 600), starts
accepting requests. ~3 seconds. One launch-time exception to fail-fast:
when a macOS XProtect definition update SIGKILLs the pinned Chromium at
spawn, the daemon classifies the kill signature, clears the quarantine
flag on the Playwright cache, reinstalls the pinned revision from the
gstack install root (bounded ~120s), and retries once — at most once per
daemon process. If the heal can't complete, the original launch error
plus manual `bunx playwright install chromium` guidance lands on daemon
stderr (see `browse-daemon.log`). Wired at all three launch sites in
`browser-manager.ts` via `browse/src/xprotect-heal.ts`.
2. **Subsequent calls.** CLI reads the state file, sends an HTTP POST with
the bearer token, prints the response. ~100-200ms round trip.
3. **Idle shutdown.** After 30 minutes of no commands, daemon shuts down and
cleans up the state file. Next call restarts it.
4. **Crash recovery.** If Chromium crashes, the daemon exits immediately —
no self-healing, don't hide failure. CLI detects the dead daemon on the
next call and starts a fresh one.
5. **Busy vs dead.** A daemon that stops answering HTTP while its process is
alive is busy, not dead. The CLI gives `/health` a bounded ~8s to recover,
then reports busy with a nonzero exit — it never kills an alive pid.
Only an explicit `--force-restart` replaces a live-but-unresponsive
daemon (tabs, cookies, and logins are lost). `browse stop` against a
daemon that already died is success: the desired end state holds, so it
cleans the stale state file instead of booting a daemon just to stop it.
### Multi-workspace isolation
Each project root (detected via `git rev-parse --show-toplevel`) gets its
own daemon, port, state file, cookies, and logs. No cross-workspace
collisions. State at `<project>/.gstack/browse.json`.
| Workspace | State file | Port |
|-----------|-----------|------|
| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (1000049151) |
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (1000049151) |
---
## Command reference
~70 commands across read, write, and meta. Selectors accept CSS, `@e` refs
from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
### Reading
| Command | Description |
|---------|-------------|
| `text [sel]` | Clean page text (or scoped to a selector) |
| `html [sel]` | innerHTML, or full page HTML if no selector |
| `links` | All links as `text → href` |
| `forms` | Form fields as JSON |
| `accessibility` | Full ARIA tree |
| `media [--images\|--videos\|--audio] [sel]` | Media elements with URLs, dimensions, types |
| `data [--jsonld\|--og\|--meta\|--twitter]` | Structured data: JSON-LD, OG, Twitter Cards, meta tags |
### Inspection
| Command | Description |
|---------|-------------|
| `js <expr> [--out <file>] [--raw]` | Run inline JavaScript expression in page context, return as string. With `--out <file>` the result is written to disk instead of returned (a `data:*;base64,...` result is decoded to raw bytes unless `--raw`). `--out` makes the invocation a WRITE (needs `write` scope, never allowed over the tunnel). |
| `eval <file> [--out <file>] [--raw]` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`). `--out`/`--raw` behave as for `js`. |
| `css <sel> <prop>` | Computed CSS value |
| `attrs <sel\|@ref>` | Element attributes as JSON |
| `is <prop> <sel\|@ref>` | State check: visible, hidden, enabled, disabled, checked, editable, focused |
| `console [--clear\|--errors]` | Captured console messages |
| `network [--clear]` | Captured network requests |
| `dialog [--clear]` | Captured dialog messages |
| `cookies` | All cookies as JSON |
| `storage` / `storage set <key> <val>` | Read both localStorage + sessionStorage; set localStorage |
| `perf` | Page load timings |
| `inspect [sel] [--all] [--history]` | Deep CSS via CDP — full rule cascade, box model, computed styles |
| `ux-audit` | Page structure for behavioral analysis: site ID, nav, headings, text blocks, interactive elements |
| `cdp <Domain.method> [json-params]` | Raw CDP method dispatch (deny-default; allowlist in `cdp-allowlist.ts`) |
### Navigation
| Command | Description |
|---------|-------------|
| `goto <url>` | Navigate to URL (`http://`, `https://`, `file://`) |
| `load-html <file>` | Load local HTML in memory (no `file://` URL; survives viewport scale changes) |
| `back`, `forward`, `reload` | Standard nav |
| `url` | Current page URL |
| `wait <sel\|--networkidle\|--load>` | Wait for element, network idle, or page load (15s timeout) |
### Interaction
| Command | Description |
|---------|-------------|
| `click <sel\|@ref>` | Click element |
| `fill <sel> <val>` | Fill input |
| `select <sel> <val>` | Select dropdown option (value, label, or visible text) |
| `hover <sel>` | Hover element |
| `type <text>` | Type into focused element |
| `press <key>` | Playwright keyboard key (case-sensitive: Enter, Tab, ArrowUp, Shift+Enter, Control+A, ...) |
| `scroll [sel\|@ref]` | Scroll element into view, or jump to page bottom if no selector |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size + optional `deviceScaleFactor` 1-3 (retina screenshots) |
| `upload <sel> <file> [...]` | Upload file(s) |
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt; text is sent for prompts |
| `dialog-dismiss` | Auto-dismiss next dialog |
### Style + cleanup
| Command | Description |
|---------|-------------|
| `style <sel> <prop> <val>` | Modify CSS property (with undo support) |
| `style --undo [N]` | Undo last N style changes |
| `cleanup [--ads\|--cookies\|--sticky\|--social\|--all]` | Remove page clutter |
| `prettyscreenshot [--scroll-to <sel\|text>] [--cleanup] [--hide <sel>...] [path]` | Clean screenshot with optional cleanup, scroll, hide |
### Visual
| Command | Description |
|---------|-------------|
| `screenshot [--selector <css>] [--viewport] [--clip x,y,w,h] [--base64] [sel\|@ref] [path]` | Five modes: full page, viewport, element crop, region clip, base64 |
| `pdf [path] [--format letter\|a4\|legal] [...]` | PDF with full layout: format, width/height, margins, header/footer templates, page numbers, --tagged for accessibility, --toc waits for Paged.js |
| `responsive [prefix]` | Three screenshots: mobile (375x812), tablet (768x1024), desktop (1280x720) |
| `diff <url1> <url2>` | Text diff between two URLs |
### Cookies + headers
| Command | Description |
|---------|-------------|
| `cookie <name>=<value>` | Set cookie on current page domain |
| `cookie-import <json>` | Import cookies from JSON file |
| `cookie-import-browser [browser] [--domain d]` | Import from installed Chromium browsers (interactive picker, or `--domain` for direct import) |
| `header <name>:<value>` | Set custom request header (sensitive values auto-redacted) |
| `useragent <string>` | Set user agent (triggers context recreation, invalidates refs) |
### Tabs + frames
| Command | Description |
|---------|-------------|
| `tabs` | List open tabs |
| `tab <id>` | Switch to tab |
| `newtab [url] [--json]` | Open new tab; `--json` returns `{tabId, url}` for programmatic use |
| `closetab [id]` | Close tab |
| `tab-each <command> [args...]` | Fan out a command across every open tab; returns JSON |
| `frame <sel\|@ref\|--name n\|--url pattern\|main>` | Switch to iframe context (or back to main); clears refs |
### Extraction
| Command | Description |
|---------|-------------|
| `download <url\|@ref> [path] [--base64]` | Download URL or media element using browser cookies |
| `scrape <images\|videos\|media> [--selector] [--dir] [--limit]` | Bulk download all media from page; writes `manifest.json` |
| `archive [path]` | Save complete page as MHTML via CDP |
### Snapshot
| Command | Description |
|---------|-------------|
| `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]` | Accessibility tree with `@e` refs; `-i` interactive only, `-c` compact, `-d N` depth, `-s` scope, `-D` diff vs previous, `-a` annotated screenshot, `-C` cursor-interactive `@c` refs |
### Server lifecycle
| Command | Description |
|---------|-------------|
| `status` | Daemon health + mode (headless / headed / cdp) |
| `stop` | Shut down daemon (succeeds even if the daemon already died — never boots one just to stop it) |
| `restart` | Restart daemon |
| `connect` | Launch headed GStack Browser with Side Panel extension |
| `disconnect` | Close headed Chrome, return to headless |
| `focus [@ref]` | Bring headed Chrome to foreground (macOS); `@ref` also scrolls into view |
| `state save\|load <name>` | Save or load browser state (cookies + URLs) |
| `memory [--json]` | Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. Use `--json` for programmatic consumers; text mode renders sorted top-10 tabs with "and N more" tail. |
The daemon's own stdout/stderr persists to `<project>/.gstack/browse-daemon.log`
(append mode, rotated to `.log.1` at the size cap, single generation), with
tokens and unsanitized page content kept out — check it when a daemon dies
without an obvious cause. A live-but-unresponsive daemon is never auto-killed;
pass `--force-restart` to replace it explicitly (see "Daemon lifecycle" above).
### Handoff
| Command | Description |
|---------|-------------|
| `handoff [reason]` | Open visible Chrome at current page for user takeover (CAPTCHA, MFA, complex auth) |
| `resume` | Re-snapshot after user takeover, return control to AI |
### Meta + chains
| Command | Description |
|---------|-------------|
| `chain` (JSON via stdin) | Run a sequence of commands. Pipe `[["cmd","arg1",...],...]` to `$B chain`. Stops at first error. |
| `inbox [--clear]` | List messages from sidebar scout inbox |
| `watch [stop]` | Passive observation — periodic snapshots while user browses; `stop` returns summary |
### Browser-skills runtime
| Command | Description |
|---------|-------------|
| `skill list` | List all browser-skills with resolved tier (project > global > bundled) |
| `skill show <name>` | Print SKILL.md |
| `skill run <name> [--arg k=v...] [--timeout=Ns]` | Spawn the skill script with a per-spawn scoped token |
| `skill test <name>` | Run the skill's `script.test.ts` against bundled fixtures |
| `skill rm <name> [--global]` | Tombstone a user-tier skill |
### Domain-skills
| Command | Description |
|---------|-------------|
| `domain-skill save\|list\|show\|edit\|promote-to-global\|rollback\|rm <host?>` | Per-site agent notes (host derived from active tab). Lifecycle: quarantined → active (after N=3 successful uses without classifier flag) → global (explicit promote) |
Aliases: `setcontent`, `set-content`, `setContent``load-html` (canonicalized
before scope checks, so a read-scoped token can't use the alias to run a
write command).
---
## Snapshot system
The browser's key innovation is **ref-based element selection** built on
Playwright's accessibility tree API. No DOM mutation. No injected scripts.
Just Playwright's native AX API.
### How `@ref` works
1. `page.locator(scope).ariaSnapshot()` returns a YAML-like accessibility tree.
2. The snapshot parser assigns refs (`@e1`, `@e2`, ...) to each element.
3. For each ref, it builds a Playwright `Locator` (using `getByRole` + nth-child).
4. The ref→Locator map is stored on `BrowserManager`.
5. Later commands like `click @e3` look up the Locator and call `locator.click()`.
### Ref staleness detection
SPAs can mutate the DOM without navigation (React router, tab switches,
modals). When this happens, refs collected from a previous `snapshot` may
point to elements that no longer exist. `resolveRef()` runs an async
`count()` check before using any ref — if the element count is 0, it throws
immediately with a message telling the agent to re-run `snapshot`. Fails fast
(~5ms) instead of waiting for Playwright's 30-second action timeout.
### Extended snapshot features
- **`--diff` (`-D`).** Stores each snapshot as a baseline. On the next `-D`
call, returns a unified diff showing what changed. Use this to verify that
an action (click, fill, etc.) actually worked.
- **`--annotate` (`-a`).** Injects temporary overlay divs at each ref's
bounding box, takes a screenshot with ref labels visible, then removes the
overlays. Use `-o <path>` to control the output.
- **`--cursor-interactive` (`-C`).** Scans for non-ARIA interactive elements
(divs with `cursor:pointer`, `onclick`, `tabindex>=0`) using `page.evaluate`.
Assigns `@c1`, `@c2`... refs with deterministic `nth-child` CSS selectors.
These are elements the ARIA tree misses but users can still click.
---
## Browser-skills runtime
Per-task directories that codify a repeated browser flow into a deterministic
Playwright script. The compounding layer.
### Anatomy of a browser-skill
```
browser-skills/<name>/
├── SKILL.md # frontmatter + prose contract
├── script.ts # deterministic Playwright-via-browse-client logic
├── _lib/browse-client.ts # vendored copy of the SDK (~3KB, byte-identical to canonical)
├── fixtures/<host>-<date>.html # captured page for fixture-replay tests
└── script.test.ts # parser tests against the fixture (no daemon required)
```
The bundled reference is `browser-skills/hackernews-frontpage/`: scrapes the
HN front page, returns 30 stories as JSON. Try it:
```bash
$B skill list # shows hackernews-frontpage (bundled)
$B skill show hackernews-frontpage
$B skill run hackernews-frontpage # JSON of 30 stories in ~200ms
$B skill test hackernews-frontpage # runs script.test.ts against fixture
```
### Three-tier storage
`$B skill list` walks all three in priority order; first hit wins. Resolved
tier is printed inline next to each skill name:
| Tier | Path | When |
|------|------|------|
| **Project** | `<project>/.gstack/browser-skills/<name>/` | Project-specific skills (committed or gitignored) |
| **Global** | `~/.gstack/browser-skills/<name>/` | Per-user skills, all projects |
| **Bundled** | `<gstack-install>/browser-skills/<name>/` | Ships with gstack, read-only |
### Trust model
Two orthogonal axes — daemon-side capability and process-side env — independently
configured.
| Axis | Mechanism | Default |
|------|-----------|---------|
| **Daemon-side capability** | Per-spawn scoped token bound to read+write scope (browser-driving commands minus admin: `eval`, `js`, `cookies`, `storage`). Single-use clientId encodes skill name + spawn id. Revoked when spawn exits. | Always scoped — never the daemon root token |
| **Process-side env** | `trusted: true` frontmatter passes `process.env` minus `GSTACK_TOKEN`. `trusted: false` (default) drops everything except a minimal allowlist (LANG, LC_ALL, TERM, TZ) and pattern-strips secrets (TOKEN/KEY/SECRET/PASSWORD, AWS_*, ANTHROPIC_*, OPENAI_*, GITHUB_*, etc.) | Untrusted (must opt in) |
`GSTACK_PORT` and `GSTACK_SKILL_TOKEN` are injected last, so a parent process
can't override them.
### Output protocol
stdout = JSON. stderr = streaming logs. Exit 0 / non-zero. Default 60s
timeout, override via `--timeout=Ns`. Max stdout 1MB (truncate + non-zero
exit if exceeded). Matches `gh` / `kubectl` / `docker` conventions.
### How the SDK distribution works
Each skill ships its own copy of `browse-client.ts` at `_lib/browse-client.ts`,
byte-identical to the canonical `browse/src/browse-client.ts`. `/skillify`
copies the canonical SDK alongside every generated script. Each skill is
fully self-contained: copy the directory anywhere, it runs. Version drift
impossible — the SDK is frozen at the version the skill was authored against.
### Atomic write discipline (`/skillify` D3)
`browse/src/browser-skill-write.ts` provides three primitives:
- `stageSkill(opts)` — writes files to `~/.gstack/.tmp/skillify-<spawnId>/<name>/`
with restrictive perms.
- `commitSkill(opts)` — atomic `fs.renameSync` into the final tier path.
Refuses to follow symlinked staging dirs (`lstat` check), refuses to
clobber existing skills, runs `realpath` discipline on the tier root.
- `discardStaged(stagedDir)``rm -rf` the staged dir + per-spawn wrapper.
Idempotent. Called on test failure or approval rejection.
There is no "almost shipped" state. Tests pass + user approves = atomic
rename. Tests fail or user rejects = staging vanishes.
See [`docs/designs/BROWSER_SKILLS_V1.md`](docs/designs/BROWSER_SKILLS_V1.md)
for the full design rationale.
---
## Domain-skills
Different mental model from browser-skills: agent-authored *notes* about a
site (not deterministic scripts). One per hostname. Lifecycle:
1. `domain-skill save <host>` — agent writes a note about the site (e.g.,
"GitHub: PR creation needs `--draft` flag for non-staff", "X.com: timeline
uses cursor pagination, not page numbers"). Default state: **quarantined**.
2. After **N=3** successful uses without the L4 prompt-injection classifier
flagging the note, it auto-promotes to **active**.
3. `domain-skill promote-to-global <host>` lifts it to the global tier
(machine-wide, all projects).
4. `domain-skill rollback <host>` demotes; `domain-skill rm <host>` tombstones.
The classifier flag is set automatically by the L4 prompt-injection scan;
agents do not set it manually.
Storage:
- Per-project: `<project>/.gstack/domain-skills/<host>.md`
- Global: `~/.gstack/domain-skills/<host>.md`
Source: `browse/src/domain-skills.ts`, `domain-skill-commands.ts`.
---
## Real-browser mode
`$B connect` launches **GStack Browser** — a rebranded Chromium controlled by
Playwright with the Side Panel extension auto-loaded and anti-bot stealth
patches applied. You watch every command tick through a visible window in
real time.
```bash
$B connect # launches GStack Browser, headed
$B goto https://app.com # navigates in the visible window
$B snapshot -i # refs from the real page
$B click @e3 # clicks in the real window
$B focus # bring window to foreground (macOS)
$B status # shows Mode: cdp
$B disconnect # back to headless mode
```
The window has a subtle golden shimmer line at the top and a floating
"gstack" pill in the bottom-right corner so you always know which Chrome
window is being controlled.
### What "GStack Browser" means
Not your daily Chrome — a Playwright-managed Chromium with custom branding
in the Dock and menu bar (the `.app` name, Dock icon, and tray, NOT the UA
string), always-on Layer C anti-bot stealth (most JS-observable automation
tells are masked, so many anti-bot-protected sites load cleanly), a
stock-Chrome user agent that reports the underlying Chromium version, and the
gstack extension pre-loaded via `launchPersistentContext`. The UA no longer
carries a `GStackBrowser` suffix — that branding string was itself a
high-entropy tell, so the browser now reports a plain `Chrome/<version>` UA.
Deepest-layer CDP-protocol detection still gets through (Google can still
trigger captchas; see the CDP-patch item in `TODOS.md`). Your regular Chrome
with your tabs and bookmarks stays untouched.
### When to use headed mode
- **QA testing** where you want to watch Claude click through your app
- **Design review** where you need to see exactly what Claude sees
- **Debugging** where headless behavior differs from real Chrome
- **Demos** where you're sharing your screen
- **Pair-agent** sessions (the remote agent drives your local browser)
### CDP-aware skills
When in real-browser mode, `/qa` and `/design-review` automatically skip
cookie import prompts and headless workarounds — the headed browser already
has whatever session you logged into.
### Headed mode + proxy + browser-native downloads (v1.28.0.0)
Three coordinated flags for sites that block headless browsers, fingerprint
Playwright defaults, or sit behind authenticated upstream proxies:
```bash
# Visible Chromium. Auto-spawns Xvfb on Linux containers without DISPLAY.
$B --headed goto https://example.com
# SOCKS5 with auth — Chromium can't prompt for SOCKS5 creds, so $B runs a
# local 127.0.0.1 bridge that handles the auth handshake.
$B --proxy socks5://user:pass@residential.proxy.host:1080 goto https://example.com
# HTTP/HTTPS proxy passes through to Chromium directly.
$B --proxy http://corp-proxy:3128 goto https://example.com
# Browser-native download for Content-Disposition, redirect chains, anti-bot
# CDNs where page.request.fetch() falls over.
$B download "https://protected.example.com/file" /tmp/file.bin --navigate
# Combined.
$B --headed --proxy socks5://user:pass@host:1080 \
download "https://protected.example.com/file" /tmp/file.bin --navigate
```
**Credential policy.** Pass creds via the URL (`socks5://user:pass@host`) OR
the env vars `BROWSE_PROXY_USER` / `BROWSE_PROXY_PASS` — never both. `$B`
refuses with a clear hint when both are set; silent override created
"works on my machine" debugging traps.
**Daemon discipline.** `--proxy` and `--headed` are daemon-startup config.
A running daemon with config A meeting a new invocation with config B exits
1 with a `browse disconnect` hint instead of silently restarting and dropping
tab state, cookies, or sessions.
**Stealth scope (Layer C, always on).** Every context — headless `launch`,
`--headed`/`--proxy`, `handoff`, and the `useragent`/`viewport --scale`
rebuild (`recreateContext`) — gets the full Layer C mask, no opt-in flag.
Layer C masks `navigator.webdriver`, restores the `window.chrome.*` shape
(`runtime`, `app`, `csi`, `loadTimes`), aligns `Notification.permission`
with the Permissions API, reports a per-install
`hardwareConcurrency`/`deviceMemory` from the host profile, sweeps the known
Selenium/Phantom/Nightmare/Playwright globals, and installs a
`Function.prototype.toString` proxy so every patched getter reports
`[native code]` even under the depth-3 recursion check. It still does NOT
fake `navigator.plugins` or `navigator.languages` — modern fingerprinters
cross-check those for consistency, and synthesizing fixed values flags MORE
bot-like, not less. ChromeDriver's `cdc_`/`__webdriver` runtime artifacts and
the Permissions notifications tell are also cleaned up on every path.
`GSTACK_STEALTH=extended` (also accepts `1` or `true`; off by default) layers
six more aggressive patches on top — WebGL renderer spoof, a faked
`navigator.plugins` PluginArray, `navigator.mediaDevices`. That mode actively
lies and can break sites that reflect on those properties; use it only when
the default triggers detection. For gbrowser builds with the C++ patches, the
`GSTACK_*` host-profile env (GPU vendor/renderer, UA-CH platform/model,
hardware) emits the Pack 1 `--gstack-gpu-vendor` / `--gstack-gpu-renderer` /
`--gstack-ua-platform` / `--gstack-ua-model` / `--gstack-hw-concurrency` /
`--gstack-device-memory` switches that push the GPU/UA-CH/hardware spoof down
to native code, and `GSTACK_CDP_STEALTH=on` (or `1`/`true`) emits the Pack 2
`--gstack-suppress-prepare-stack-trace` switch (closes the Cloudflare
`Error.prepareStackTrace` canary). On stock Playwright Chromium every one of
these switches is a safe no-op.
`launchHeaded` / `handoff` also strip Playwright's automation-tell launch
defaults via `ignoreDefaultArgs` (`STEALTH_IGNORE_DEFAULT_ARGS`):
`--enable-automation` (the "Chrome is being controlled by automated test
software" infobar), `--disable-extensions`,
`--disable-component-extensions-with-background-pages`,
`--disable-popup-blocking`, `--disable-component-update`, and
`--disable-default-apps`.
**Container support.** `--headed` on Linux without `DISPLAY` walks the
display range (`:99`, `:100`, ...) until `xdpyinfo` reports a free slot,
then spawns Xvfb. Cleanup-on-disconnect validates the recorded PID's
`/proc/<pid>/cmdline` matches `Xvfb` AND start-time matches before sending
any signal — no PID-reuse footguns. Skips spawn entirely when
`WAYLAND_DISPLAY` is set (Chromium uses Wayland natively). Standard
Debian/Ubuntu containers work out of the box; minimal images (alpine,
distroless) may need fonts/dbus/gtk libs for headed Chromium to render.
**Failure modes.** SOCKS5 upstream rejected or unreachable — fail-fast at
startup with a redacted error after 3 retries (5s budget). Mid-stream
upstream drop — bridge kills the affected client connection only; no
transport retries that could corrupt browser traffic.
---
## Side Panel + sidebar agent
The Chrome extension that ships baked into GStack Browser shows a live
activity feed of every browse command in a Side Panel, plus `@ref` overlays
on the page, plus an interactive Claude PTY inside the sidebar.
### The Terminal pane (the headline)
The Side Panel's primary surface is the **Terminal pane** — a live `claude -p`
PTY you can type into directly from the sidebar. Activity / Refs / Inspector
are debug overlays behind the footer's `debug` toggle. WebSocket auth uses
`Sec-WebSocket-Protocol` (browsers can't set `Authorization` on a WebSocket
upgrade), and the PTY session token is a 30-minute HttpOnly cookie minted
via `POST /pty-session`.
The toolbar's Cleanup button and the Inspector's "Send to Code" action both
pipe text into the live Claude PTY via `window.gstackInjectToTerminal(text)`,
exposed by `sidepanel-terminal.js`. There's no separate `/sidebar-command`
POST — the live REPL is the only execution surface.
### Activity feed
A scrolling feed of every browse command — name, args, duration, status,
errors. Shows up in real time as Claude works. Backed by SSE (`/activity/stream`)
that accepts the Bearer token OR the HttpOnly `gstack_sse` session cookie
(30-minute stream-scope cookie minted via `POST /sse-session`).
### Refs tab
After `$B snapshot`, shows the current `@ref` list (role + name) so you can
see what Claude is targeting.
### CSS Inspector
Powered by `$B inspect` (CDP-based). Click any element on the page to see the
full CSS rule cascade, computed styles, box model, and modification history.
The "Send to Code" button injects a description into the Claude PTY.
### Sidebar architecture
| Component | Where it lives | Notes |
|-----------|----------------|-------|
| Side Panel UI | `extension/sidepanel.js`, `sidepanel-terminal.js` | Chrome extension surface |
| Background SW | `extension/background.js` | Manages tab events, port management |
| Content script | `extension/content.js` | Page overlays, `gstack` pill |
| Terminal agent | `browse/src/terminal-agent.ts` | PTY spawn, lifecycle, auth |
| Sidebar utilities | `browse/src/sidebar-utils.ts` | URL sanitization, helpers |
Before modifying any of these, read the comment block in `CLAUDE.md` under
"Sidebar architecture" — silent failures here usually trace to not understanding
the cross-component flow.
### Manual install (for your regular Chrome)
If you want the extension in your everyday Chrome (not the Playwright-controlled
one):
```bash
bin/gstack-extension # opens chrome://extensions, copies path to clipboard
```
Or do it manually: `chrome://extensions` → toggle Developer mode → Load
unpacked → navigate to `~/.claude/skills/gstack/extension` → pin the
extension → enter the port from `$B status`.
v1.63 pinned the extension identity via the manifest `key` field, so existing
unpacked installs get a new extension ID and panel-local state (saved port)
resets once — a one-time in-product notice explains this.
---
## Pair-agent
Remote AI agents (Codex, OpenClaw, Hermes, anything that speaks HTTP) can
drive your local browser through an ngrok tunnel. The whole flow is gated
by a 26-command allowlist, scoped tokens, and a denial log.
### How it works
```bash
/pair-agent # generates a setup key, prints connection instructions
# Copy the instructions to the remote agent
# Remote agent runs:
# POST <tunnel-url>/connect with setup key → gets a scoped token (24h, single client)
# POST <tunnel-url>/command with token → runs allowed commands
```
### Dual-listener architecture (v1.6.0.0+)
When `pair-agent` activates, the daemon binds **two HTTP listeners**:
- **Local listener** (`127.0.0.1:LOCAL_PORT`). Full command surface. Never
forwarded by ngrok. Used by your Claude Code, the Side Panel, anything
on your machine.
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`). Locked allowlist —
`/connect`, `/command` (scoped tokens + 26-command browser-driving
allowlist), `/sidebar-chat`. ngrok forwards only this port.
Root tokens sent over the tunnel return 403. SSE endpoints use a 30-minute
HttpOnly `gstack_sse` cookie (never valid against `/command`).
### The 26-command tunnel allowlist
Defined in `browse/src/server.ts` as `TUNNEL_COMMANDS`. Pure gate function
`canDispatchOverTunnel(command)` is exported for unit testing. Set:
```
goto, click, text, screenshot, html, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, eval,
newtab, tabs, back, forward, reload, snapshot, fill, url, closetab
```
Notably absent: `pair`, `unpair`, `cookies`, `setup`, `launch`, `restart`,
`stop`, `tunnel-start`, `token-mint`, `state`, `connect`, `disconnect`. A
remote agent that tries them gets a 403 plus a fresh entry in the denial log.
### Tunnel denial log
`~/.gstack/security/attempts.jsonl` — append-only, salted SHA-256 of source
+ domain only (no raw IP, no full request body), rotates at 10MB with 5
generations. Per-device salt at `~/.gstack/security/device-salt` (mode 0600).
### Tunnel egress receipts (v1.63+)
Every tunnel session open writes a hash-chained egress receipt (sink
`browse-tunnel`) to `~/.gstack/security/egress.jsonl` BEFORE ngrok forwards
anything. Fail-closed: if the receipt can't be written, the tunnel listener
is torn down and the start is refused. Inspect the ledger with
`bin/gstack-egress list` and verify chain integrity with
`bin/gstack-egress verify` (exit 3 on tamper).
See [`docs/REMOTE_BROWSER_ACCESS.md`](docs/REMOTE_BROWSER_ACCESS.md) for the
full operator guide.
### Tab ownership
Scoped tokens default to `tabPolicy: 'own-only'`. A paired agent can `newtab`
to create its own tab and drive that tab freely, but it can't `goto`, `fill`,
or `click` on tabs another caller owns. `tabs` lists ALL tab metadata (an
accepted tradeoff — see ARCHITECTURE.md), but `text`/`html`/`snapshot` content
of unowned tabs is blocked by ownership checks.
---
## Authentication
Three token types, three lifetimes, three scopes.
| Token | Generated by | Lifetime | Scope |
|-------|--------------|----------|-------|
| **Root token** | Daemon startup (random UUID) | Daemon process lifetime | Full command surface, local listener only — 403 over tunnel |
| **Setup key** | `POST /pair` | 5 minutes, one-time use | Single redemption: present at `/connect`, get a scoped token |
| **Scoped token** | `POST /connect` (with setup key) | 24 hours | Per-client, allowlist-bound, optionally tab-scoped |
The root token is written to `<project>/.gstack/browse.json` with chmod 600.
Every command that mutates browser state must include
`Authorization: Bearer <token>`.
### SSE session cookie (v1.6.0.0+)
SSE endpoints (`/activity/stream`, `/inspector/events`) accept the Bearer
token OR a 30-minute HttpOnly `gstack_sse` cookie minted via
`POST /sse-session`. The `?token=<ROOT>` query-param auth is no longer
supported. This is what lets the Chrome extension subscribe to the activity
feed without putting the root token in extension storage.
### PTY session cookie
The Terminal pane uses a separate session cookie, `gstack_pty`, minted via
`POST /pty-session`. Different scope — can spawn / drive the live `claude`
PTY, can't dispatch arbitrary `/command` calls. `/health` endpoint MUST NOT
surface this token.
### Extension token bootstrap (v1.63+)
`GET /health` is liveness/status only — it never carries a token, in any
mode. The Side Panel extension bootstraps the root token via
`POST /extension-token` on the local listener. The server releases the
token only when the caller's Origin is exactly
`chrome-extension://<GSTACK_EXTENSION_ID>` — the `key` field in
`extension/manifest.json` pins the extension ID (`GSTACK_EXTENSION_ID` in
`browse/src/server.ts`; derivation reproducible via
`bun browse/scripts/extension-id.ts`) — AND the parsed Host hostname is
loopback. Anything else gets a detail-free 403. The endpoint is never
added to `TUNNEL_PATHS`, so the tunnel surface 404s it by default-deny.
### Token registry
`browse/src/token-registry.ts` handles mint/validate/revoke for all three
types, plus per-token rate limiting. Setup keys are single-use; scoped
tokens have a sliding 24h window; the root token is rotated on each daemon
startup.
---
## Security stack
Layered defense against prompt injection on untrusted page content.
| Layer | Module | Lives in |
|-------|--------|----------|
| **L1** Datamarking | `content-security.ts` | server + page-content read path |
| **L2** Hidden-element strip | `content-security.ts` | server + page-content read path |
| **L3** ARIA + URL blocklist + envelope wrapping | `content-security.ts` | server + page-content read path |
| **L4** TestSavantAI ML classifier (112MB ONNX) | `security-classifier.ts` | security sidecar subprocess* |
| Canary token utilities | `security.ts` | pure functions — no live injector today |
| `combineVerdict` ensemble | `security.ts` | server (inline L4 verdict path) |
\* `security-classifier.ts` cannot be imported from the compiled browse
binary — `@huggingface/transformers` v4 requires `onnxruntime-node` which
fails to `dlopen` from Bun compile's temp extract dir. The compiled binary
runs L1L3 plus the pure parts of `security.ts`; L4 runs in a plain-Node
sidecar (`security-sidecar-entry.ts`, spawned lazily by
`security-sidecar-client.ts` on the first `/pty-inject-scan`).
### Thresholds
- `BLOCK: 0.85` — single-layer score that would cause BLOCK if cross-confirmed
- `WARN: 0.75` — cross-confirm threshold in `combineVerdict`
- `LOG_ONLY: 0.40` — log-only floor
- `SOLO_CONTENT_BLOCK: 0.92` — single-layer threshold for label-less content classifiers
### Ensemble rule
`combineVerdict` retains multi-layer ensemble semantics (2-of-N block votes;
single-layer high confidence degrades to WARN — the Stack Overflow
instruction-writing FP mitigation), but only L4 (testsavant) is live today:
the Haiku transcript and DeBERTa ensemble layers were removed along with the
sidebar chat pipeline that hosted them. **Canary leak always BLOCKs
(deterministic).**
### Env knobs
- `GSTACK_SECURITY_OFF=1` — emergency kill switch. Classifier stays off
even if warmed. Just the ML scan is skipped.
- Classifier model cache: `~/.gstack/models/testsavant-small/` (112MB, first
run only).
- Attack log: `~/.gstack/security/attempts.jsonl` (salted SHA-256 + domain
only, rotates at 10MB, 5 generations).
- Per-device salt: `~/.gstack/security/device-salt` (0600).
There is no security status indicator in the sidebar and no `security`
field on `/health` (#2557): the session-state file that fed them lost its
only writer when the chat-path agent was removed, so they reported stale or
empty data. The live defenses report through their own call sites. See
ARCHITECTURE.md § "Prompt injection defense" for the full threat model.
---
## Screenshots, PDFs, visual
### Screenshot modes
| Mode | Syntax | Playwright API |
|------|--------|----------------|
| Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` |
| Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` |
| Element crop (flag) | `screenshot --selector <css> [path]` | `locator.screenshot()` |
| Element crop (positional) | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` |
| Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` |
Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c`
refs. **Tag selectors like `button` aren't caught by the positional
heuristic** — use the `--selector` flag form.
`--base64` returns `data:image/png;base64,...` instead of writing to disk —
composes with `--selector`, `--clip`, `--viewport`.
Mutual exclusion: `--clip` + selector, `--viewport` + `--clip`, and
`--selector` + positional selector all throw.
### Retina screenshots — `viewport --scale`
`viewport --scale <n>` sets Playwright's `deviceScaleFactor` (context-level,
13 cap):
```bash
$B viewport 480x600 --scale 2
$B load-html /tmp/card.html
$B screenshot /tmp/card.png --selector .card
# .card at 400x200 CSS pixels → card.png is 800x400 pixels
```
`--scale N` alone (no `WxH`) keeps the current viewport size. Scale changes
trigger a context recreation, which invalidates `@e`/`@c` refs — rerun
`snapshot` after. HTML loaded via `load-html` survives the recreation via
in-memory replay. Rejected in headed mode (real browser controls scale).
### PDF generation
`pdf` accepts the full Playwright surface plus a few additions:
- **Layout:** `--format letter|a4|legal`, `--width <dim>`, `--height <dim>`,
`--margins <dim>`, `--margin-top/right/bottom/left <dim>`
- **Structure:** `--toc` (waits for Paged.js if loaded), `--outline`,
`--tagged` (PDF/A accessibility), `--print-background`,
`--prefer-css-page-size`
- **Branding:** `--header-template <html>`, `--footer-template <html>`,
`--page-numbers`
- **Tabs:** `--tab-id <N>` to render a specific tab
- **Large payloads:** `--from-file <payload.json>` (avoids shell argv limits)
### Responsive screenshots
`responsive [prefix]` — three screenshots in one call: mobile (375x812),
tablet (768x1024), desktop (1280x720). Saves as `{prefix}-mobile.png` etc.
### `prettyscreenshot`
Combines cleanup + scroll + element hide in one call:
```bash
$B prettyscreenshot --cleanup --scroll-to "hero section" --hide ".cookie-banner" /tmp/clean.png
```
---
## Local HTML
Two ways to render HTML that isn't on a web server:
| Approach | When | URL after | Relative assets |
|----------|------|-----------|-----------------|
| `goto file://<abs-path>` | File already on disk | `file:///...` | Resolve against file's directory |
| `goto file://./<rel>`, `goto file://~/<rel>` | Smart-parsed to absolute | `file:///...` | Same |
| `load-html <file>` | HTML generated in memory, no parent-dir context needed | `about:blank` | Broken (self-contained HTML only) |
Both are scoped to files under cwd or `$TMPDIR` via the same safe-dirs
policy as `eval`. `file://` URLs preserve query strings and fragments (SPA
routes work).
`load-html` has an extension allowlist (`.html`, `.htm`, `.xhtml`, `.svg`) and
a magic-byte sniff to reject binary files mis-renamed as HTML. 50MB size cap
(override via `GSTACK_BROWSE_MAX_HTML_BYTES`).
`load-html` content survives later `viewport --scale` calls via in-memory
replay (TabSession tracks the loaded HTML + waitUntil). The replay is
purely in-memory — HTML is never persisted to disk via `state save` to
avoid leaking secrets or customer data.
---
## Batch endpoint
`POST /batch` sends multiple commands in a single HTTP request. Eliminates
per-command round-trip latency — critical for remote agents over ngrok where
each HTTP call costs 2-5s.
```json
POST /batch
Authorization: Bearer <token>
{
"commands": [
{"command": "text", "tabId": 1},
{"command": "text", "tabId": 2},
{"command": "snapshot", "args": ["-i"], "tabId": 3},
{"command": "click", "args": ["@e5"], "tabId": 4}
]
}
```
Each command routes through `handleCommandInternal` — full security pipeline
(scope checks, domain validation, tab ownership, content wrapping) enforced
per command. Per-command error isolation: one failure doesn't abort the
batch. Max 50 commands per batch. Nested batches rejected. Rate limiting:
1 batch = 1 request against the per-agent limit.
Pattern: agent crawling 20 pages opens 20 tabs (individual `newtab` or
batch), then `POST /batch` with 20 `text` commands → 20 page contents in
~2-3 seconds total vs ~40-100 seconds serial.
---
## Capture
Console, network, and dialog events flow into O(1) circular buffers (50,000
capacity each), flushed to disk asynchronously via `Bun.write()`:
- Console: `.gstack/browse-console.log`
- Network: `.gstack/browse-network.log`
- Dialog: `.gstack/browse-dialog.log`
The `console`, `network`, and `dialog` commands read from the in-memory
buffers (not disk) so capture is real-time even when disk is slow.
Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent
browser lockup. `dialog-accept <text>` controls prompt response text.
---
## JS execution
`js` runs an inline expression. `eval` runs a JS file. Both run in the
**same JS sandbox** — the only difference is inline-vs-file. Both support
`await` — expressions containing `await` are auto-wrapped in an async
context:
```bash
$B js "await fetch('/api/data').then(r => r.json())" # auto-wrapped
$B js "document.title" # no wrap needed
$B eval my-script.js # file with await
```
For `eval` files, single-line files return the expression value directly.
Multi-line files need explicit `return` when using `await`. Comments
containing the literal token "await" don't trigger wrapping.
Path safety: `eval` rejects paths outside cwd or `/tmp`. `js` doesn't read
files at all.
---
## Tabs, frames, state
### Tabs
```bash
$B tabs # list all open tabs
$B tab 3 # switch to tab 3
$B newtab https://example.com # open new tab, switch to it
$B newtab --json # programmatic: returns {"tabId":N,"url":...}
$B closetab # close current
$B closetab 2 # close tab 2
$B tab-each "text" # run "text" on every tab, return JSON
```
`tab-each <command>` fans out a command across every open tab and returns a
JSON array — handy for "give me the text of every tab I have open."
### Frames
```bash
$B frame "#stripe-iframe" # switch to iframe by selector
$B frame @e7 # by ref
$B frame --name "checkout" # by name attribute
$B frame --url "stripe.com" # by URL pattern match
$B frame main # back to top frame
```
Refs are cleared on switch (the iframe has its own AX tree).
### State save/load
```bash
$B state save my-session # save cookies + URLs to .gstack/browse-state-my-session.json
$B state load my-session # restore
```
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
secrets to disk).
Manual save/load is one-shot. For state that survives daemon restarts
automatically, opt in with `BROWSE_PERSIST_STATE=1` in the daemon's
environment: the headless daemon snapshots cookies + per-tab
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600,
atomic writes) every 30 seconds and at clean shutdown, then restores it off
the boot path on the next launch. Default OFF — cookies on disk are a real
cost, so the user opts in. Headless only (headed mode's persistent Chromium
profile already owns its state). Loaded HTML and tab ownership are never
persisted, cookies for localhost, `.internal`, loopback IP literals
(127.0.0.0/8, `::1`), and link-local/cloud-metadata addresses
(169.254.0.0/16) are dropped on restore, and a corrupt snapshot is quarantined to
`session-state.json.corrupt` so persistence can never block a launch.
### Watch
```bash
$B watch # passive observation: snapshot every 5s while user browses
$B watch stop # return summary of what changed
```
Useful when you're driving the browser manually and want Claude to see what
you did at the end without spamming `snapshot` calls.
### Inbox
```bash
$B inbox # list messages from sidebar scout
$B inbox --clear # clear after reading
```
The sidebar scout (a background process the Chrome extension can spawn) drops
notes for Claude when the user surfaces something they want noticed. Stored
in `.gstack/browser-scout.jsonl`.
---
## CDP
### `$B cdp` — raw Chrome DevTools Protocol dispatch
Deny-default. Only methods enumerated in `browse/src/cdp-allowlist.ts`
(`CDP_ALLOWLIST` const) are reachable; any other method returns 403. Each
allowlist entry declares scope (tab vs browser) and output (trusted vs
untrusted). Untrusted methods (data-exfil-shaped, e.g.
`Network.getResponseBody`) get UNTRUSTED-envelope wrapped output.
```bash
$B cdp Page.getLayoutMetrics
$B cdp Network.enable
$B cdp Accessibility.getFullAXTree '{"depth":5}'
# Perf measurement on a simulated low-end client (overrides persist on the
# tab until you clear them — callers own restoration):
$B cdp Emulation.setCPUThrottlingRate '{"rate":4}' # clear: '{"rate":1}'
$B cdp Network.emulateNetworkConditions '{"offline":false,"latency":150,"downloadThroughput":195000,"uploadThroughput":97500}'
# clear: '{"offline":false,"latency":0,"downloadThroughput":-1,"uploadThroughput":-1}'
```
To discover allowed methods: read `browse/src/cdp-allowlist.ts`.
### `$B inspect` — CDP-based CSS inspector
```bash
$B inspect ".header" # full rule cascade for the header
$B inspect ".header" --all # include user-agent rules
$B inspect ".header" --history # show modification history
```
Returns the matched rule cascade with specificity, computed styles, the box
model, and (with `--history`) every CSS modification made via `$B style` since
the page loaded. Powered by a persistent CDP session per page in
`browse/src/cdp-inspector.ts`.
### `$B ux-audit`
```bash
$B ux-audit
```
Returns JSON with site identity, navigation, headings (capped 50), text
blocks, interactive elements (capped 200) — page structure for behavioral
analysis without dumping the full HTML. Used by `/qa` and `/design-review`
for cheap coverage maps.
---
## Performance
| Tool | First call | Subsequent calls | Context overhead per call |
|------|-----------|------------------|---------------------------|
| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) |
| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) |
| **gstack browse** | **~3s** | **~100-200ms** | **0 tokens** (plain text stdout) |
| **gstack browse + codified skill** | **~3s** | **~200ms** | **0 tokens** (single skill invocation) |
In a 20-command browser session, MCP tools burn 30,00040,000 tokens on
protocol framing alone. gstack burns zero. The codified-skill path takes a
20-command session down to a single `$B skill run` call.
### Why CLI over MCP
MCP works well for remote services. For local browser automation it adds
pure overhead:
- **Context bloat** — every MCP call includes full JSON schemas. A simple
"get the page text" costs 10x more context tokens than it should.
- **Connection fragility** — persistent WebSocket/stdio connections drop
and fail to reconnect.
- **Unnecessary abstraction** — Claude already has a Bash tool. A CLI that
prints to stdout is the simplest possible interface.
gstack skips all of this. Compiled binary. Plain text in, plain text out.
No protocol. No schema. No connection management.
---
## Multi-workspace
Each project root (detected via `git rev-parse --show-toplevel`) gets its
own daemon, port, state file, cookies, and logs. No cross-workspace
collisions.
| Workspace | State file | Port |
|-----------|-----------|------|
| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (1000049151) |
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (1000049151) |
Browser-skills three-tier lookup walks project → global → bundled, so a
project-tier skill at `/code/project-a/.gstack/browser-skills/foo/` shadows
the global `~/.gstack/browser-skills/foo/` only inside project-a.
---
## Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BROWSE_PORT` | 0 (random 1000049151) | Fixed port for the HTTP server (debug override) |
| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms |
| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file |
| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to `server.ts` |
| `BROWSE_CDP_URL` | (none) | Set to `channel:chrome` for real-browser mode |
| `BROWSE_CDP_PORT` | 0 | CDP port (used internally) |
| `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) |
| `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) |
| `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok |
| `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap |
| `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier |
| `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. |
| `GSTACK_CDP_STEALTH` | unset | Set to `on`/`1`/`true` to emit `--gstack-suppress-prepare-stack-trace` (gbrowser Pack 2 / B11 C++ patch only; no-op on stock Chromium) |
| `GSTACK_GPU_VENDOR`, `GSTACK_GPU_RENDERER`, `GSTACK_GPU_CHIPSET` | unset | Per-install GPU spoof fed to the Pack 1 WebGL/UA-CH C++ patches. Set by gbd from the host profile; emitted as `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / `--gstack-ua-model` cmdline switches only when present. |
| `GSTACK_PLATFORM` | unset | Host platform classification (`MacARM`/`MacIntel``macOS`, `Win32``Windows`, `Linux*``Linux`) emitted as `--gstack-ua-platform` |
| `GSTACK_HW_CONCURRENCY`, `GSTACK_DEVICE_MEMORY` | host profile (fallback 8) | Per-install `hardwareConcurrency`/`deviceMemory` reported by Layer C and emitted as `--gstack-hw-concurrency` / `--gstack-device-memory` for the worker-navigator C++ patch |
---
## Source map
```
browse/
├── src/
│ ├── cli.ts # Thin client — reads state, sends HTTP, prints
│ ├── server.ts # Bun HTTP daemon — routes commands, dual-listener
│ ├── browser-manager.ts # Chromium lifecycle, tabs, ref map, crash detection
│ ├── port-allocator.ts # Fixed 10000-49151 scan range for every long-lived listener (never port:0)
│ ├── xprotect-heal.ts # macOS XProtect launch-kill classify + quarantine-clear + bounded reinstall
│ ├── socks-bridge.ts # Local 127.0.0.1 SOCKS5 bridge that handles auth handshakes Chromium can't speak
│ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both)
│ ├── proxy-redact.ts # Cred-redaction helper for any proxy URL surfaced to logs/errors
│ ├── xvfb.ts # Xvfb auto-spawn + orphan cleanup with PID + start-time validation
│ ├── stealth.ts # Layer C: webdriver mask + window.chrome.* + Notification/Permissions + per-install hardware + toString proxy + automation-global sweep; buildGStackLaunchArgs (GSTACK_* cmdline switches); GSTACK_STEALTH=extended opt-in
│ ├── browse-client.ts # Canonical SDK — what skills import as _lib/browse-client.ts
│ ├── snapshot.ts # AX tree → @e/@c refs → Locator map; -D/-a/-C handling
│ ├── read-commands.ts # Non-mutating: text, html, links, js, css, is, dialog, ...
│ ├── write-commands.ts # Mutating: goto, click, fill, upload, dialog-accept, ...
│ ├── meta-commands.ts # state, watch, inbox, frame, ux-audit, chain, diff, ...
│ ├── browser-skills.ts # 3-tier walk + frontmatter parser + tombstones
│ ├── browser-skill-commands.ts # $B skill list/show/run/test/rm + spawnSkill
│ ├── browser-skill-write.ts # D3 atomic stage/commit/discard helper for /skillify
│ ├── skill-token.ts # mintSkillToken / revokeSkillToken (per-spawn, scoped)
│ ├── domain-skills.ts # Per-site agent notes (state machine: quarantined→active→global)
│ ├── domain-skill-commands.ts # $B domain-skill save/list/show/edit/promote/rollback/rm
│ ├── cdp-allowlist.ts # Deny-default CDP method allowlist
│ ├── cdp-bridge.ts # CDP session lifecycle bridge
│ ├── cdp-commands.ts # $B cdp dispatcher
│ ├── cdp-inspector.ts # $B inspect — persistent CDP session per page
│ ├── activity.ts # ActivityEntry, CircularBuffer, SSE subscribers, privacy filtering
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
│ ├── tunnel-denial-log.ts # ~/.gstack/security/attempts.jsonl writer (salted)
│ ├── path-security.ts # validateOutputPath / validateReadPath / validateTempPath
│ ├── url-validation.ts # URL safety checks for goto
│ ├── content-security.ts # L1-L3: datamarking, hidden strip, ARIA, URL blocklist, envelopes
│ ├── security.ts # L5 canary + L6 verdict combiner + thresholds
│ ├── security-classifier.ts # L4 ML classifier (TestSavantAI, runs in the security sidecar)
│ ├── security-sidecar-entry.ts # Sidecar subprocess entrypoint hosting the ONNX classifier
│ ├── security-sidecar-client.ts # server.ts-side client that drives the sidecar
│ ├── terminal-agent.ts # Side Panel Claude PTY manager (auth + lifecycle)
│ ├── sidebar-utils.ts # Sidebar URL sanitization + helpers
│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers
│ ├── cookie-picker-routes.ts # HTTP routes for /cookie-picker/*
│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker
│ ├── network-capture.ts # Network request capture for $B network
│ ├── media-extract.ts # Media element extraction for $B media
│ ├── project-slug.ts # Project slug derivation for state paths
│ ├── error-handling.ts # safeUnlink / safeKill / isProcessAlive
│ ├── platform.ts # OS detection (macOS, Linux, Windows)
│ ├── telemetry.ts # Anonymous opt-in usage telemetry
│ ├── find-browse.ts # Locate running daemon or bootstrap
│ └── config.ts # Config resolution (env / files)
├── test/ # Integration tests + HTML fixtures
└── dist/
└── browse # Compiled binary (~58MB, Bun --compile)
browser-skills/
└── hackernews-frontpage/ # Bundled reference skill
├── SKILL.md
├── script.ts
├── _lib/browse-client.ts
├── fixtures/hn-2026-04-26.html
└── script.test.ts
scrape/SKILL.md.tmpl # /scrape gstack skill — match-or-prototype entry point
skillify/SKILL.md.tmpl # /skillify gstack skill — codify last /scrape into permanent skill
```
---
## Development
### Prerequisites
- [Bun](https://bun.sh/) v1.0+
- Playwright's Chromium (installed automatically by `bun install`)
### Quick start
```bash
bun install # install deps + Playwright Chromium
bun test # all integration tests (~3s for browse-only)
bun run dev <cmd> # run CLI from source (no compile)
bun run build # compile to browse/dist/browse
```
### Dev mode vs compiled binary
During development, use `bun run dev` instead of the compiled binary. It runs
`browse/src/cli.ts` directly with Bun, so you get instant feedback:
```bash
bun run dev goto https://example.com
bun run dev text
bun run dev snapshot -i
bun run dev click @e3
```
The compiled binary (`bun run build`) is only needed for distribution. It
produces a single ~58MB executable at `browse/dist/browse` using Bun's
`--compile` flag.
### Running tests
```bash
bun test # all tests
bun test browse/test/commands # command integration tests
bun test browse/test/snapshot # snapshot tests
bun test browse/test/cookie-import-browser # cookie import unit tests
bun test browse/test/browser-skill-write # D3 atomic-write helper tests
bun test browse/test/tunnel-gate-unit # canDispatchOverTunnel pure tests
```
Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML
fixtures from `browse/test/fixtures/`, then exercise the CLI against those
pages.
### Adding a new command
1. Add the handler in `read-commands.ts` (non-mutating) or `write-commands.ts`
(mutating), or `meta-commands.ts` (server / lifecycle).
2. Register the route in `server.ts`.
3. Add the entry to `COMMAND_DESCRIPTIONS` in `browse/src/commands.ts` (with
a clear `description` and `usage` — the `gen-skill-docs` validation
suite enforces no `|` characters in `description`).
4. Add a test case in `browse/test/commands.test.ts` with an HTML fixture
if needed.
5. Run `bun test` to verify.
6. Run `bun run build` to compile.
7. Run `bun run gen:skill-docs` to regenerate SKILL.md (the command appears
in the command-reference table downstream).
### Adding a new browser-skill
For a hand-written skill: copy `browser-skills/hackernews-frontpage/`,
update SKILL.md frontmatter, rewrite `script.ts` against your target site,
re-capture the fixture, update the parser test. `bun test` validates the
SKILL.md contract (sibling SDK byte-identity, frontmatter schema).
For an agent-written skill: drive the page once with `/scrape <intent>`,
say `/skillify`, accept the proposed name in the approval gate. The skill
lands at `~/.gstack/browser-skills/<name>/` after the test passes.
### Deploying to the active skill
The active skill lives at `~/.claude/skills/gstack/`. After making changes:
```bash
cd ~/.claude/skills/gstack
git fetch origin && git reset --hard origin/main
bun run build
```
Or copy the binary directly:
```bash
cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse
```
---
## Cross-references
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — system-level architecture, dual-listener tunnel design, prompt-injection defense threat model
- [`CLAUDE.md`](CLAUDE.md) — project-level instructions, sidebar architecture notes, security-stack constraints
- [`docs/REMOTE_BROWSER_ACCESS.md`](docs/REMOTE_BROWSER_ACCESS.md) — operator guide for `/pair-agent` (setup keys, scoped tokens, denial log)
- [`docs/designs/BROWSER_SKILLS_V1.md`](docs/designs/BROWSER_SKILLS_V1.md) — design doc for browser-skills runtime (Phase 1 + 2a + roadmap)
- [`scrape/SKILL.md`](scrape/SKILL.md) — `/scrape` skill: match-or-prototype data extraction
- [`skillify/SKILL.md`](skillify/SKILL.md) — `/skillify` skill: codify last `/scrape` into permanent skill
- [`TODOS.md`](TODOS.md) — `/automate` (Phase 2b P0), Phase 3 resolver injection, Phase 4 eval + sandbox
---
## Acknowledgments
The browser automation layer is built on [Playwright](https://playwright.dev/)
by Microsoft. Playwright's accessibility tree API, locator system, and
headless Chromium management are what make ref-based interaction possible.
The snapshot system — assigning `@ref` labels to AX tree nodes and mapping
them back to Playwright Locators — is built entirely on top of Playwright's
primitives. Thank you to the Playwright team for building such a solid
foundation.
The prompt-injection L4 layer uses
[TestSavantAI/distilbert-v1.1-32](https://huggingface.co/TestSavantAI/distilbert-v1.1-32)
(112MB ONNX), run locally via `@huggingface/transformers`.
The CDP escape hatch is gated by an allowlist directly inspired by Codex's
T2 outside-voice review during the v1.4 design pass: deny-default with an
explicit allowlist, not allow-default with a denylist.