Files
gstack/BROWSER.md
T
2be6c06ba8 v1.65.0.0 feat: fork port wave 2 — feature fixes, session persistence, Apple releases, supply-chain CI (#2577)
* fix(memory-ingest): pass --include-gitignored to gbrain import

gstack-artifacts-init writes an ignore-everything .gitignore (a bare `*`,
headed "Do not edit") at the root of ~/.gstack. The memory ingest stages
pages into ~/.gstack/.staging-ingest-<pid>-<ts>/, which is inside that
repo, and gbrain's markdown collector honours .gitignore. The collector
therefore matches every staged file against `*` and collects zero.

The failure is silent. gbrain import exits 0 having imported nothing while
the ingest prints `written: N` from the STAGED count rather than the
imported count, so a run that indexes nothing looks identical to a healthy
one and the memory corpus quietly stops growing.

Reproduction, using git's own ignore machinery (no gbrain needed):

  git init .
  mkdir -p .staging-ingest-12345/learnings
  echo x > .staging-ingest-12345/learnings/page.md
  printf '*\n' > .gitignore
  git ls-files --others --exclude-standard   # -> empty

Passing --include-gitignored makes the import independent of whatever
.gitignore sits above the staging directory. Adding a negation to the
generated .gitignore is the alternative, but that file is gstack-owned and
marked "Do not edit", so any regeneration silently reintroduces the bug.

Adds a regression pin in the shape of memory-ingest-no-put_page.test.ts,
plus a behavioural test for the collision itself. Both source pins fail
against the unpatched file.

* fix(memory-ingest): GIT_CEILING_DIRECTORIES defense-in-depth on the import child (#2144)

Second layer under #2560's --include-gitignored: a realpath'd ceiling at the
staging dir's parent pushes any git-enumerating collector off the git fast
path (which sees zero files under ~/.gstack's ignore-everything root) onto
its plain FS walk, even on gbrain builds whose flag semantics drift. Ceiling
is realpath'd because git compares canonicalized directories during
discovery — a staging dir reached through a symlink (macOS /var ->
/private/var, symlinked $GSTACK_HOME) otherwise never matches.

Behavioral tests prove discovery stops at the ceiling from the staging dir,
including through a symlinked path, using git itself — no gbrain required.

Mechanism ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018)

The branch+commit jq filter piped to the split commit array and then
referenced .commit — jq rebinds context across a pipe, so .commit indexed
the ARRAY with a string, every input line errored into 2>/dev/null, and
|| true swallowed the exit. The aggregate table has been empty for every
user since the feature shipped. Bind .commit to a variable before the pipe.

Functional pin extracts the ACTUAL emitted jq program from the resolver and
runs it against fixture JSONL (verified RED against the broken filter), plus
a source-shape guard against reintroducing a context-rebinding reference.

Fix mechanism from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(codex): BSD mktemp templates broke /codex on every macOS install (#2091)

macOS mktemp requires the X's to end the template; the five
"codex-*-XXXXXX.txt" sites failed with "mkstemp failed ... File exists"
before Codex ever ran (reproduced live on this machine). Same class fixed
in claude/SKILL.md.tmpl's three sites. bin/gstack-paths now strips macOS's
trailing slash from TMPDIR so TMP_ROOT-built paths stop carrying "//".

Static tripwire scans every tracked .tmpl for characters after the X-run in
a mktemp template (longer X-runs stay valid), plus a live portability check
of the emitted shape.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(decision-log): --supersede silently discarded the replacement decision

The supersede/redact branch appended the retirement event and exited before
the JSON argument was ever read — a user recording a reversal WITH its
replacement lost the replacement, and the payload finder's first-non-flag-arg
predicate would have mistaken the target id for JSON anyway.

Payloads are now identified by their leading brace, validated BEFORE any
write, and appended FIRST (retirement second), so the only visible
interleaving under a crash is both-active — recoverable, never lost. The
replacement carries supersedes:<old-id> provenance. Bare --supersede <id>
(the documented reversal-without-replacement) stays legal; --redact with a
payload now refuses instead of dropping it.

Ported from time-attack/gstack (GStack 2), tests included.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(brain-context): cold-start probe latency permanently disabled gbrain context

gbrainAvailable() spawned gbrain --version under a 500ms budget; a cold CLI
start on a loaded machine blew the timeout, misclassified gbrain as missing,
and every skill session silently ran brainless — plus the per-query re-probe
burned 3x the budget before any real work. Replaced with a memoized
stat-based PATH scan (PATHEXT-aware on Windows) and made the query timeout
overridable via GSTACK_BRAIN_TIMEOUT_MS for loaded CI environments.

Also picks up the fork's manifest-filter coverage (#1687 shape) against the
fake-gbrain harness — passes against our existing filter support.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup-gbrain): voyage-code-3 flags were silently dropped under zsh (#1798)

zsh does not word-split an unquoted $VAR, so all three PGLite-init sites
passed the entire flag string as ONE argv word — gbrain ignored it and
silently fell back to its default embedding model, downgrading code
retrieval for every zsh user (macOS default shell). Flags now ride the
positional params (set -- ...; "$@").

Tests run the shape under BOTH bash and zsh against the fake-gbrain argv
recorder (per-word argc log distinguishes one-blob from split), include a
demonstration of the zsh collision on the old shape, and pin the template's
three sites statically.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model-benchmark): recognize macOS Keychain auth in the claude adapter (#1890)

The default macOS Claude Code install stores OAuth under the Keychain
generic-password service "Claude Code-credentials" and never writes
~/.claude/.credentials.json, so available()'s file-or-env sniff reported
"No Claude auth found" while claude -p worked fine. On darwin the sniff
now also probes the Keychain entry — metadata only (no -w, the secret is
never read), 5s timeout, any security(1) failure degrades to not-found.

Verified live on this machine (subscription install, no creds file,
Keychain entry present).

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

* fix(upgrade): v1.27 migration no longer auto-proceeds without a TTY or records a failed rename as done (#1383)

Two silent-failure shapes in one script. Non-interactive runs (Claude Code
Bash tool, CI) blanket-auto-proceeded into a REMOTE repo rename — now they
skip-for-now by default and ask again next upgrade; unattended runs opt in
with GSTACK_MIGRATE_ASSUME_YES=1. And a failed gh rename was journaled as
done and the done-touchfile written, permanently stranding a half-renamed
install — the failed step now stays PENDING with the manual command printed,
finalize refuses the done-marker while any step is unjournaled, and the
migration exits 1 with a re-run pointer while completed steps still skip on
retry.

Harness updated to opt in explicitly; new tests pin the default-skip and
failure-stays-pending-then-retry-succeeds contracts (13/13).

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

* fix(ship): REST fallback when gh pr edit hits the Projects-classic GraphQL deprecation (#1079)

On repos where GitHub enforces the Projects-classic sunset, gh pr edit
hard-errors on repository.pullRequest.projectCards and Step 19's PR body
update dies. The template now names the error shape, says it is not an auth
problem, and falls back to the REST endpoint (gh api pulls/N -X PATCH) with
the SAME already-redaction-scanned temp file for body and title. Generated
SKILL.md regen rides the cluster regen commit.

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

* fix(ship): test-command detection was blind to Django and config-less-but-tested projects

The Test Framework Bootstrap detected Python only via requirements.txt or
pyproject.toml and treated missing config files as no-tests, so a green
'python manage.py test' Django app, a Go project with *_test.go beside the
source, in-source Rust #[test] blocks, or a package.json with only a test
script all got offered a SECOND test framework over a working one.

Detection now enumerates definitive per-ecosystem markers (manage.py,
tox.ini/setup.cfg, pom.xml/gradle, Makefile test targets, a tracked-file
test census, in-source Rust tests) as EVIDENCE for the question it asks —
never a command to run blind — preserving the read-CLAUDE.md-or-ask
contract, with a marker→candidate-command table and ask-once persistence.
The shared coverage-audit detection block gains the same markers.

Test runs the resolver's emitted detection bash against Django / Go / Rust /
Node fixtures in throwaway git repos.

Ported from time-attack/gstack commit e3259078 (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate SKILL.md files for cluster A (autoplan jq, codex mktemp, setup-gbrain zsh, ship detection + REST fallback)

Atomic regen of the 9 generated files whose templates/resolvers changed in
the A-cluster commits. bun run gen:skill-docs, no hand edits.

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

* test: refresh ship goldens + parity ratios for cluster A growth

Codex/Factory hosts render single-file ship skills whose committed goldens
must track template changes; refreshed from the regenerated renders. Parity
size guards bumped with the growth itemized — ship (carve-guards) 1.08 ->
1.10 for the detection-evidence + REST-fallback growth measured at 1.090x,
qa (parity-harness monolith invariant) 1.07 -> 1.12 for the shared
coverage-audit markers measured at 1.111x. Kept tight so the next growth is
a deliberate decision, not drift; the Apple adapter raises ship again with
its own justification.

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

* fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path)

The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored
by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose —
a direct or cron invocation of gstack-gbrain-sync ingested repo code
regardless. Worse: the code stage's egress receipt has cited 'per-repo
policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such
function existed. repoPolicyTier() now gates the stage before the dry-run
branch: deny → refused-policy-deny (exit 1, loud), read-only → clean
skipped-policy-read-only (code ingest writes pages), unreadable store →
fail-closed refused-policy-unreadable, no store → unchanged fail-open.

Subprocess tests pin all four paths against real git repos and a
permission-blocked store (verified RED against the ungated binary). The
receipt's consent string is truthful from this commit. #2140's ingest-path
source-isolation ask remains open — partial-progress comment at ship.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process

The loopback sessions list echoed live tokens — a harvest-and-replay
primitive for anything on the machine (same class as the /health token leak
fixed in v1.63). The list now returns a device-salted 16-hex token_id plus
metadata; the salt is shared with the attempts log so identifiers correlate.
/auth/revoke keeps the list→revoke workflow alive by accepting token_id
alongside the caller's own raw token and identity. saltedHash() is exported
from audit.ts and writeAttempt now reuses it (was inlined).

Integration tests pin raw-token absence, the id shape/metadata, and the
token_id revoke round-trip (verified RED against the leaking handler).

List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket

The StateServer's bootstrap announce logged the live boot token with
privacy: .public — and nothing consumed it: the daemon has read the token
from the 0600 app-container file since the devicectl copy flow landed. The
log line handed a credential to anything reading the unified log during the
launch window. It now announces port/build only.

The IPv4 listener bound the wildcard interface and relied on the
per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it
now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6
keeps the wildcard bind for CoreDevice ULA peers by design.

Static pins cover both the template and the fixture app copy.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(make-pdf): close the offline-gate bypass via raw-HTML fetch vectors

With --allow-network off, the sanitizer stripped script/iframe/link but let
Chromium fetch remote resources at print time through four raw-HTML vectors:
<style> @import (any form), remote url() in <style> blocks and inline style
attributes (incl. protocol-relative //), srcset with a remote candidate
(Chromium prefers srcset over the inlined src), and remote src/poster on
video/audio/source/track. All neutralized at the sanitizer; remote <img src>
is deliberately left for the image inliner so its blocked-remote placeholder
still fires, and url() mentions in prose/code spans stay untouched.

Fork's test suite ported verbatim (12 cases incl. the end-to-end render
assertion), verified RED against the old sanitizer.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pair-agent): tunnel activation is consent-gated — and the receipt's consent claim is now real

The tunnel egress receipts have claimed consent: 'pair_agent=on' since v1.63
while no such key or gate existed — ngrok installed+authed was enough for
the CLI to auto-start an internet-facing tunnel. isPairAgentEnabled() (fail-
closed, env-overridable) now gates all three activation points: CLI
auto-start, POST /tunnel/start (refuses with the enable hint), and the
BROWSE_TUNNEL=1 startup bind. Consent-on-first-use, not silent breakage:
the /pair-agent skill asks once (one-way-door posture), sets pair_agent via
gstack-config (registered with on|off validation, default off), and never
asks again; direct API callers get the same hint in the refusal.

Adapted from the fork's gate: their reader targeted config.json, which on
main would have made the gate silently un-enableable — ours reads the
canonical ~/.gstack/config.yaml with the JSON shape as fallback, pinned by
tests either way (11 cases, gate wiring tripwires included).

Ported from time-attack/gstack (GStack 2), store adaptation ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate pair-agent SKILL.md for cluster B (consent gate)

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

* fix(browse): cancel the parent watchdog when handoff promotes a daemon to headed

The parent-process watchdog assumes connection mode is fixed at boot: headless
daemons outlive their parent, headed ones do not. The env guards
(BROWSE_PARENT_PID=0, BROWSE_HEADED=1) only cover daemons that were headed when
they started.

handoff breaks that assumption. It swaps in a headed context on a RUNNING daemon
and sets connectionMode = 'headed' without a restart, so a daemon that
legitimately registered a watchdog lands on the fatal side of the branch. The
parent is usually a short-lived shell, and Claude Code's Bash tool kills one after
every invocation, so the next 15s poll shuts the daemon down.

The user-visible effect is that handoff destroys the thing it just created. It
exists so a human can log in, solve a CAPTCHA, or clear an MFA prompt; the browser
disappears about fifteen seconds later and takes the session with it. Observed
while driving two registrar control panels: five daemon deaths and three logins,
each one discarding the authenticated session.

BrowserManager now exposes onHeadedPromotion, fired only on runtime promotion and
not on a headed boot, and the server binds it to a canceller for the interval it
already owned but previously discarded. Bound on both the module-level manager and
any embedder-supplied one, since the watchdog reads activeBrowserManager and
binding only the default would let embedders promote silently.

The binding sits next to the browserManager declaration rather than next to
clearParentWatchdog. Placing it with the function, which lives with the watchdog it
cancels, reads better but touches browserManager in its temporal dead zone, which
aborts module evaluation and leaves every later const uninitialized. findport
tests catch that immediately.

Tests: watchdog.test.ts already noted in its header that its three cases all fix
mode via env at spawn time, so none reaches the headed branch. Driving a real
handoff needs a headed Chromium, so the wiring is pinned with static tripwires
instead, matching cdp-session-cleanup.test.ts and server-auth.test.ts. Verified
they fail when the notification call is removed and pass when restored.

Full `bun test` shows the same 6 pre-existing failures on this branch and on main
(gstack-gbrain-detect, gstack-artifacts-init), which pass in isolation on both, so
they are test-order pollution rather than a regression here.

* fix(browse): pass windowsHide so the daemon stops popping console windows

On Windows, `browse` leaves empty black console windows on top of whatever the
user is doing — they pop up every few minutes for as long as any browser skill
is alive, and outlive the process that created them.

Cause: `bun-polyfill.cjs` maps `Bun.spawn`/`Bun.spawnSync` onto node's
`child_process`, and node defaults `windowsHide` to **false**. Bun never creates
these windows, so nothing in the daemon's own code looks wrong — the behaviour
only appears on the node fallback path.

The one users notice is `spawnTerminalAgent()`, which launches
`bun run terminal-agent.ts` through this shim. The daemon respawns it on a
watchdog, so closing the window is not enough — a new one arrives shortly after.
Ten `bun.exe` processes were live on the machine this was diagnosed on.

Why they linger after the child exits: with the default terminal application set
to "Let Windows decide", the console is brokered through Windows Terminal via
svchost, and WT leaves the empty frame behind when its only child exits. The
frame has no child process at all, which is why it looks like a dead terminal.

Setting `windowsHide: true` on both wrappers fixes every console child routed
through the shim — the bun agent plus the `tasklist`, `git` and `powershell`
calls elsewhere in the daemon. No behaviour change on macOS or Linux, where the
option is ignored.

Not covered by this commit: `chromium.launch()` goes through playwright's own
process launcher rather than this shim, so it still creates one window per daemon
start. Worth a follow-up.

* test(browse): make bun-polyfill tests runnable on Windows, and cover windowsHide

`bun test browse/test/bun-polyfill.test.ts` was **0 pass / 4 fail on Windows**
before this — every test in the file, on the platform the polyfill exists to
support.

Each test interpolates the polyfill's absolute path into a single-quoted JS
string passed to `node -e`. On Windows that path has backslashes, so JS eats
them as escapes:

    'C:\Users\jwilk\dev\gstack-fork\browse\src\bun-polyfill.cjs'
      ->  C:Usersjwilkdevgstack-forkrowsesrcun-polyfill.cjs

(`\b` is a real escape, so it deletes a character too.) `require()` throws, the
subprocess dies, stdout is empty, and every assertion compares against "". The
tests pass on macOS and Linux purely because those paths have no backslashes.

Fixed by interpolating with `JSON.stringify(polyfillPath)`, which quotes and
escapes correctly on all platforms.

Also adds a regression test for the windowsHide fix in the previous commit. It
stubs `child_process.spawn`/`spawnSync` *before* the polyfill destructures them
and asserts the captured options, so it is deterministic and needs no window —
it verifies the contract on macOS and Linux too, where the option is a no-op.

Verified on Windows: 5 pass / 0 fail with the fix, and the new test alone fails
("VISIBLE" instead of "HIDDEN") when the previous commit is reverted.

* fix(browse): forward windowsHide through the Bun polyfill spawn shims

The Node fallback shim accepts a Bun.spawn options object and forwards
only stdio, env and cwd to child_process.spawn. windowsHide is dropped,
and because Node defaults it to false while Bun.spawn hides the console
window, the omission inverts the behavior on the one platform the shim
exists to support.

Symptom: the terminal-agent respawn in server.ts (60s watchdog ticker)
pops a visible bun.exe console window on Windows every time it fires,
so the window keeps coming back with no scheduled task or startup entry
behind it. stdio:'ignore' silences the child's output but does not
suppress its window.

Both shims now forward the option and default it to true, matching the
Bun API being emulated; an explicit windowsHide:false still passes
through. spawnTerminalAgent also sets it explicitly at the call site.

Tests: three cases in browse/test/bun-polyfill.test.ts assert the
default for spawn and spawnSync and that an explicit false is honored.
Each was confirmed to fail against the unpatched shim.

Drive-by, required to run the suite at all on Windows: the tests
interpolated an absolute path into a JS string literal, so backslashes
were consumed as escapes and every require() failed with
MODULE_NOT_FOUND. The path is now normalized to forward slashes. On
Windows this file went from 0/4 passing to 7/7.

* fix(browse): headed mode on macOS 26 — stop mutating the signed Chromium bundle, heal the ones we already broke (#2242, #2138, #2139)

The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist
(global name replace — which also renamed CFBundleExecutable to a binary
that doesn't exist) and overwrote its Resources/*.icns, breaking the
codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The
mutation lived in the SHARED Playwright cache, so it also poisoned the
user's other Playwright projects.

Three layers land together: (1) the rebrand block is gone — branding lives
in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone
and a static tripwire (no plist/icns writes into the bundle; the tripwire
allows the read-only probe below); (2) a launch-time self-heal detects an
already-poisoned cache bundle, removes it, and errors with the exact
re-fetch command — covering deploy paths that never run migrations;
(3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones,
and re-fetches clean Chromium immediately (migrations run after ./setup, so
without the re-fetch an upgrade would end with zero working browser).
Functionally verified against fixture caches: poisoned removed, clean
untouched, rerun no-op. Migration filename tracks the final VERSION at ship.

The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice).
Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and
migration are ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): 'browse stop' no longer restarts the daemon it was asked to stop

The stop handler awaited shutdown() — which ends in process.exit — before
returning, so the acknowledgement never egressed. The CLI's fetch reset,
which its crash path reasonably interpreted as a dead daemon: it relaunched
Chromium, re-sent stop, watched the daemon exit again, and errored 'Server
crashed twice in a row'. Every stop cost a wasted Chromium launch and a
nonzero exit. The ack now returns first; shutdown fires on a 25ms unref'd
timer. Same fix for restart. Fork's test pins ack-before-teardown for both.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): lock acquisition reports real errors instead of phantom contention (#1084)

acquireServerLock's bare catch treated EVERY failure as 'another process
holds the lock' — a missing state dir, EACCES, or ENOSPC read as permanent
phantom contention with nothing to debug. Now only EEXIST is contention:
ENOENT self-heals with one mkdirSecure retry, everything else throws
ServerLockError carrying the real errno, and the stale-lock unlink/retry
loop is depth-capped so it can't livelock. Fork's five-case test ported.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(browse): integration coverage for #1781 busy-vs-dead recovery

Fork's wedged-daemon fixture: first /command connection drops, daemon PID
stays alive. Pins the whole contract — CLI retries the same daemon instance
without a kill, state file untouched, no restart, exactly two command
requests. Message-text assertion adapted: our CLI retries silently at the
probe layer where the fork announces on stderr; the behavior, not the
message, is the invariant.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): windowsHide on every Windows-reachable spawn (#1835)

Console windows flashed (and stole focus) on every daemon relaunch,
taskkill, tasklist poll, and powershell DPAPI call — node-level spawns
default windowsHide to false. Covered: the node -e launcher (outer spawnSync
AND the inner detached daemon spawn inside the launcher string), the
dev-mode bun fallback, killServer's taskkill, isProcessAlive's tasklist,
and cookie-import's powershell + tasklist. The Bun-polyfill shims were
covered by absorbed PRs #2523 + #2539 (thanks @jwilk-hrep,
@jerrynicholsai); this closes the sites those PRs didn't reach. The icacls
sites land with the #1605 DACL commit alongside the static tripwire that
pins all of them. R8's planned spawnHidden() helper is deliberately NOT
built: the polyfill default plus the tripwire achieve the no-drift goal
without indirection over seven heterogeneous call shapes. The polyfill +
spawn-hide tests join the Windows CI shard.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): self-repair broken Windows DACLs on state dirs (#1605)

icacls '/inheritance:r /grant:r' can partially fail on localized or domain
accounts: inheritance strips but the user grant doesn't resolve, leaving a
machine-SID-only DACL the owner can't even list — the sidebar/PTY failure
chain in #1605, caused by the very hardening call meant to protect the dir.
mkdirSecure now verifies listability after hardening (a real readdir —
fs.accessSync doesn't consult NTFS ACLs) and repairs via icacls /reset,
re-hardens, and if hardening breaks access again leaves inherited ACLs:
functional-but-unhardened beats hardened-but-unusable. The icacls calls
carry windowsHide (#1835's last two sites) and the fork's static spawn-hide
tripwire lands here, pinning every covered site. file-permissions.test.ts
is already in the windows-free-tests curated shard, so the DACL contract
executes on windows-latest.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(browse): opt-in session persistence — auth survives daemon restarts (#778, #2193)

BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/
sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd
interval and at clean shutdown, and restores on the next launch — killing
the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129).

Security invariants mirror state save|load: loadedHtml and owner are never
persisted and never accepted from disk; restored cookies pass the same
hygiene filter (localhost/.internal/metadata domains dropped); restoreState
re-validates every URL. Default OFF; headed mode excluded (the persistent
profile owns that state). Hardened past the fork's shape per review R3:
corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one
log line), snapshot failures warn once and never kill the daemon, and the
boot log reports restored counts or fresh-session status.

Module + 10 tests ported (MIT header retained); server wiring at launch,
interval, and shutdown; skill docs section added (regen rides the cluster
regen commit).

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate browse SKILL.md for cluster C (session persistence docs)

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

* feat(skills): third-party web-actions contract — offer to drive vendor-site steps, never just dump a manual list

When a workflow needs something done on an external website the user
controls (register an API key, create a vendor account, configure a
dashboard/webhook/OAuth app), five skills (ship, spec, office-hours,
setup-deploy, land-and-deploy) now follow one contract: offer to drive it
in a visible browser via gstack's own stack ($B headed + handoff/resume,
GStack Browser) behind ONE per-task consent question naming the exact site
and actions; passwords, payment, CAPTCHA, and identity stay user-performed;
captured secrets go to owner-only files or the user's secret store, never
chat/logs/history; and the credential is verified with one non-mutating API
call before any success claim — dashboards show masked placeholders, and a
401 catches them. Declining yields manual steps and a blocked-on-user mark;
nothing new is ever installed to close the gap.

New resolver token {{THIRD_PARTY_ACTIONS}} (adapted from the fork's
contract — their Aside-browser detection swapped for our own driver stack;
MIT portions noted). Parity guards bumped with growth itemized (ship
1.10->1.12 at measured 1.103x; office-hours skeleton 101K / 1.09 at
measured 1.079x); ship goldens refreshed.

Ported from time-attack/gstack (GStack 2), driver adaptation ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(office-hours): design docs land in the repo, written as decision records (#703, #2000)

Office-hours Phase 5 now dual-writes the design doc: the docs/designs/ copy
is what teammates and plan reviews read (committable, visible), while the
~/.gstack copy keeps memory ingest and cross-session discovery working. The
repo copy leaves the private store, so it passes the redaction scan-at-sink
first (HIGH blocks the repo copy, MEDIUM confirms per finding), and any
failure — read-only checkout, non-git dir, unconfirmed finding — degrades
to the private copy with a one-line reason, never blocking the handoff.

The doc itself is now a decision record, not a transcript: one bullet per
decision with its why, ruled-out approaches collapsed to a single line with
the rejection reason, settled/empty template sections omitted. No page cap;
extra length must come from genuinely open questions.

Plan reviews (ceo/eng/devex + the shared review resolver) prefer the
repo-local doc (DESIGN.md, then newest docs/designs/*.md) when it's at
least as fresh as the private copy — a stale old repo doc never shadows a
newer session. Parity guards bumped with measured values (three plan-review
skeletons +~0.7KB each; office-hours 1.092x).

Judgment ported from time-attack/gstack (GStack 2); scan-at-sink and
freshness-preference adaptations ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(office-hours): 'never show me these again' for the founder-resources pitch (#538)

The Phase 6 resources offer (34 PG essays + Garry/YC videos) had no
permanent decline — the reporter showed memory instructions kept being
overridden on every update, so people who said no got re-pitched forever.
The offer now closes with a standing choice; opting out runs
gstack-config set founder_resources false (new key, default true, true|false
validated), the write is VERIFIED before any promise (a failed write says so
and skips this session only), and every future session skips the entire
section silently — no resources, no 'skipped as requested' mention. Config
outlives session context, so never means never. Re-enable anytime:
gstack-config set founder_resources true. The pitch stays default-ON for
everyone who never opted out.

Tests pin the key's default/persistence/validation through the real config
bin and the generated section's gate-before-content + write-verify contract.

Approved as a promo-surface change (CEO review D3.4, 2026-08-14).
Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ship): the Apple App Store release journey — working tree to Submit for Review

Point /ship at a repo with an .xcodeproj, .xcworkspace, or app-product Swift
package and ask to release: the adapter runs the whole journey with ONE
authorization moment (membership + pricing + in-session sign-in, decision-
store persisted so repeat releases ask nothing) and one store-assets question
only when assets are missing. fastlane is the single tool (produce/cert/
sigh/gym/pilot/deliver/frameit); credential vocabulary never reaches the
user.

The adapter carries 21 live releases' worth of paid-for Apple knowledge:
the web session mints the permanent upload key itself (iris POST
/v1/apiKeys; privateKey is base64-of-PEM, downloadable only at creation) so
nobody ever types an app-specific password; error -22938 is Transporter
asking for a key, not a user task; errors are CLASSIFIED before credentials
are touched (validation/UnexpectedResponse = metadata, incl. Apple's
expanded age-rating attributes); pricing goes through POST
/v1/appPriceSchedules because fastlane's price_tier is broken against the
current API; and store distribution NEVER routes through the branch gate —
a clean tree on main is the solo shipper's normal case (Step 0.9 loads the
adapter BEFORE the gate, pinned by test with the non-Apple gate
byte-unchanged and unique). Uploads/submissions follow an idempotency-log
contract (inspect App Store Connect before any re-run). Non-Mac hosts get
the honest split: build legs via a macOS CI runner with the minted key as a
secret, API legs local. Browser use inside the journey is banned except the
named paid-app banking/tax residue. Redaction dry-run clean.

Ship's parity ratio raised 1.12 -> 1.22 deliberately: the 14.8KB section is
on-demand (Apple store targets only), one manifest line otherwise.

Ported from time-attack/gstack (GStack 2), refined across its 21 live
releases; architecture adaptation (carved section, decision-store paths,
idempotency log, third-party-actions handoff) ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer

Open a large repo (1,000+ tracked files) and gstack can offer code
intelligence ONCE, with the trade-offs stated: GBrain (semantic memory +
code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot
(self-hosted whole-repo search, local on localhost), Graphify (local
tree-sitter graph, nothing leaves the machine, user-installed), or No
indexing — a decline persists machine-wide so no skill ever asks again.
Small repos never see the question; grep stays the always-working default
and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only).

Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker +
selection + suggest, MIT headers), the gstack-code-intelligence CLI
(suggest/select/consent/index/search/status), 31 offline tests (fake CLI
shims + injected fetch), and the provider-contract design doc. Verified
live on this repo: suggest fires at 1,233 files with real availability
detail per provider.

Hardened per review: the per-remote trust store is the SINGLE consent
authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded
code-intelligence consent (fail-closed on an unreadable store, pinned by
three tests); both send-capable adapters are registered as fail-closed
MODULE_SINKS in the egress tripwire so a refactor can't drop their
receipts; and local-compute vs remote-send consents are never bundled.
setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4
glue-collapse is explicitly NOT ported.

Ported from time-attack/gstack (GStack 2); consent unification ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): supply-chain hygiene — secret gate on every PR diff, dependency review, OSV, dependabot, evidence-bar PR template

The repo owned a redaction engine and had zero CI-side secret scanning.
quality-gate.yml now pipes every PR diff's ADDED lines through our own
bin/gstack-redact (gate-secret-scan.mjs, taken from the fork — it dogfoods
the engine): HIGH findings fail the check, MEDIUM prints an advisory count
only (no human in CI to confirm), planted-bug fixtures excluded by pathspec.
Live-verified both directions: PEM key fails, clean diff and MEDIUM shapes
pass; ShellCheck (errors) covers the setup/build shell boundary and passes
today; bun audit gates critical advisories. Trigger is pull_request, never
pull_request_target.

dependency-review.yml adopts the hardened never-merged prior-art branch
(fail-on-severity high, workflow paths watched, tight perms) — verify the
dependency graph parses bun.lock with a canary bump before trusting the
gate. dependabot: weekly, grouped per ecosystem, capped PR counts; and
evals.yml image build/push now skips dependabot actors, whose read-only
GITHUB_TOKEN made every lockfile bump a permanently red check. OSV scans
weekly with a reasoned ignore file. All new workflow actions SHA-pinned.
Scorecard deliberately not taken (no consumer for the score).

The PR template front-loads the evidence bar (live proof, liveness
screenshot, no-ETHOS/voice-changes checklist); the unenforced DCO line is
dropped. bin/gstack-verify-gate ships OPT-IN (never registered by ./setup —
a Stop hook running the project's verify command after every turn is the
user's call), with the fork's tests adapted to pin exactly that.

Ported from time-attack/gstack (GStack 2) + our own prior-art branch.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove dead bins; extend the stale-ref scan to docs (the 36-release gap)

bin/chrome-cdp, bin/gstack-open-url, and bin/gstack-platform-detect were
referenced only by an audit test and CHANGELOG history — dead weight that
the stale-ref scanner should police, which required removing them FIRST.
The scanner now also sweeps docs/, README.md, and USING_GBRAIN_WITH_GSTACK
— the deliberate exclusion that let a dead command survive ~36 releases as
a command-not-found instruction. Scan is green on the extended surface.

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

* fix(bins): detect the default branch instead of hardcoding main

gstack-diff-scope fell to an empty diff (all-false SCOPE_*) and
gstack-next-version mis-based its bump math on any repo whose default
branch isn't main (trunk, master, local-only). Both now resolve
origin/HEAD -> origin/main -> origin/master -> main.

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

* fix: housekeeping sweep — telemetry integrity, persistent opt-out, context-bill accuracy, setup hang, dev-server discovery, model resolution (#2136 + v1.63 polish)

Seven small fixes, one theme (claims matching code):
- telemetry-sync strips local-only fields with jq del() (structural) instead
  of quote-fragile sed regexes; unparseable lines are dropped, never
  forwarded unstripped. Sed survives only as a jq-less fallback.
- telemetry-log rejects non-integer durations BEFORE the range caps, whose
  test(1) comparisons silently no-op on non-numerics — a malformed duration
  spliced raw text into the JSONL stream.
- browse's local telemetry honors the persistent tier (config.yaml
  telemetry: off), not just the preamble's env hint — direct $B use and
  embedders now respect the opt-out.
- gstack-context-bill --exact sees GSTACK_-promoted keys inside Conductor
  (conductor-env-shim wired at the CLI entry), and the TOTAL line no longer
  double-counts every nested skill through the root skill's walk (v1.63
  deferred polish; the telemetry-sync HTTP-status outcome deferred alongside
  it turned out already shipped).
- setup's Chromium probe is deadline-bounded (90s, background + poll-kill —
  macOS has no GNU timeout) and prefers Node for the launch probe everywhere
  (the bun --eval hang family behind #2136); the install is single-flight
  behind a lock dir with an actionable stale-lock message. Probe verified
  live on this Mac.
- the review resolver's dev-server check reads CLAUDE.md and the plan file
  before falling back to an expanded port probe, and says how to make
  itself smarter next time.
- eval/harness model IDs resolve through lib/eval-model.ts
  (GSTACK_EVAL_MODEL[_KIND] env overrides, per-kind defaults, tested) at the
  SDK-capture and PTY-warmup sites; the bash-embedded distill snippet
  mirrors the resolution inline.
- memory-ingest's silent-zero shape (staged>0, imported+unchanged==0,
  errors==0) warns even under --quiet — a run that indexes nothing must
  never look healthy again.

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

* test: wire ios-qa/daemon/test into the free suite and shard runner (E2)

The daemon's 5 test files (allowlist, audit, auth-mint, cli-mint,
daemon-integration — now 6 with session hardening) were invisible to every
runner: not in the bun test glob, not in TEST_ROOTS. The same
silent-coverage-hole class as the tracked design/test P2 — and it meant
B2's auth regression tests would never have gated. All files are hermetic
(stub state-servers on ephemeral ports, no devices); verified green in the
shard census.

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

* feat(skills): claimed limitations now require evidence, everywhere + wave follow-ups filed

Every tier-2+ skill's preamble gains one directive distilled from nine live
release failures in two days on the fork: a claimed limitation or
requirement ('the API can't do this', 'X requires a credential',
'impossible on this platform') is a material claim, stated only with the
verbatim error, the documented statement, or a live probe in hand —
pattern-matching a failure to a familiar story is not evidence, and a cheap
probe runs BEFORE asking the user or declaring a step blocked. ONE directive
adapted into the preamble resolver; the fork's full judgment contract is
deliberately not imported. Full regen (46 files), ship goldens refreshed,
parity guards bumped with the measured ~0.45KB/skill (investigate, autoplan,
plan-design-review, office-hours), Step 0.9 registered as an intentional
sub-step.

Approved deferrals filed: persona-fleet hostile-user harness + answer-key
methodology in TODOS; the fork's question-budget ACCOUNTING judgment (never
its 5/8/12 constants) folded into the V1.1 pacing design doc; the Apple
adapter added to #1882's coverage note.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(make-pdf): close offline-gate bypasses via unquoted style attrs, CSS-escape and HTML-entity obfuscation

Three live vectors found by the ship review army, all red-first tested:
unquoted style attributes skipped the remote-url neutralizer entirely;
CSS ident/string escapes (@\69mport, url(\68ttps://…)) defeated the
literal-match patterns Chromium happily decodes; and HTML entities in
style attribute values (&#104;ttps) decoded to fetchable schemes before
CSS parsing. Style-attr values are now entity-decoded in one browser-
faithful pass, escape-bearing at-rules and function tokens are dropped
fail-closed, and output is re-encoded double-quoted. 21 new test rows.

* fix(migrations): v1.65 Chromium re-fetch actually re-downloads, and success is verified before .done

The migration (renamed from the provisional v1.64.0.0 slot, which open
PR #2564 claims) deleted only the poisoned .app while Playwright's
INSTALLATION_COMPLETE marker survived in the revision dir — so the
advertised 'bunx playwright install chromium' re-fetch no-opped and the
user finished the upgrade with no browser and a success message. Now:
the whole chromium-<rev> dir goes, bunx runs cwd-pinned to the install
root, .done is gated on a verified executable, and a needs-refetch
sentinel makes re-runs retry a failed download. Stranded rev dirs
(markers without .app) also re-trigger. 6 hermetic tests, red-first.

* fix(migrations): v1.27 remediation prints a real command instead of a fictional flag

Every skip/failure path referenced '/setup-gbrain --rerun-migration',
which is implemented nowhere, and promised the migration 'will ask
again next upgrade', which the version-window runners make false. All
five sites now print the direct GSTACK_MIGRATE_ASSUME_YES=1 bash
invocation. Runner-side re-offer tracking is filed in TODOS.

* fix(browse): poisoned-bundle self-heal removes the revision dir, probes handoff too, and throws typed

Same marker flaw as the migration: rmSync of the .app alone left
INSTALLATION_COMPLETE behind, so the error message's own remediation
no-opped and the user was hard-stuck. The probe is now an exported,
unit-tested helper (probePoisonedChromiumBundle) that removes the whole
chromium-<rev> dir, never touches GSTACK_CHROMIUM_PATH custom bundles,
throws PoisonedBundleError (instanceof, not string-match), and runs on
BOTH headed entry points — launchHeaded and handoff. 7 tests.

* fix(browse): session snapshots are atomic and the cookie filter drops loopback IP literals

A crash mid-write destroyed the previous good snapshot — the exact
scenario persistence exists to survive; writes now go tmp+rename. The
internal-network cookie filter gains 127.*/::1/169.254.* (a tampered
state file could previously hand loopback-service cookies back to the
browser), and 'state load' imports the shared filter instead of
maintaining a comment-synced copy. Test cleanup made exception-safe.

* fix(browse): server runtime — restore off the boot path, shutdown that cannot hang, watchdog that still reaps tunnels

Four review findings on the wave's own new wiring: session restore ran
before Bun.serve with sequential 15s gotos while the CLI gives up at 8s
(one slow saved URL bricked every $B command) — restore now runs in the
background after bind; the shutdown snapshot gets a 2s deadline so a
wedged page.evaluate can't hold the port forever behind the new
ack-first stop; the persistence ticker gets in-flight + shutdown gates
and is cleared before the final snapshot; and the absorbed #2565
handoff fix no longer clears the whole parent watchdog — a suppress
flag keeps the tunnel-orphan reaper alive (handoff→resume→tunnel is no
longer an unreapable internet-exposed daemon). pair-agent with consent
off now names the real remedy instead of ngrok install instructions.
Lock-acquisition edge branches (garbage pidfile, vanish-race depth cap)
pinned.

* fix(browse): telemetry defaults to off like every other surface

The persistent tier defaulted ON when the config key was absent, while
gstack-config's DEFAULTS table answers 'off' for the same question —
preamble-spawned daemons and direct $B daemons disagreed about consent.
Absent key/file now means disabled; community/anonymous enable; env
kill-switch still beats everything. Both config.yaml consumers now
share one readGstackConfigYamlKey reader. 12-case consent suite.

* fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto

Four review findings on the wave's own Phase 1 port, all red-first:
'consent <repo> no' recorded consent GRANTED (the CLI ignored the
argument and always wrote true) — yes|no is now required and garbage
records nothing; Sourcebot egress receipts claimed consented=true on
paths that never checked consent — the actual consent state is threaded
into every receipt, search is fail-closed on non-loopback, and the
liveness probe's receipt says truthfully that it sends no repo content;
repoPolicyVeto only honored the deny tier while gbrain refresh writes
pages — write-class ops now veto on read-only too, matching the sync
chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32
bash invocation, spawn-vs-unreadable error distinction) used by both
call sites. Also: source ids get a host+path hash (same-name repos no
longer collide), refresh timeout raised to 120s, availability probes
run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs
for a count, and every ported file carries the fork MIT notice.
+15 tests across the two suites.

* fix(verify-gate): trust before eval, re-check on re-entry, audit every grant

The opt-in Stop hook eval'd whatever command the first CLAUDE.md up the
tree declared — any cloned repo got arbitrary shell at turn end. Now a
per-repo trust store (path+command hash, 0600) gates execution: an
untrusted or changed command never runs (exit 0 with the --trust
invocation printed), stop_hook_active re-entry re-runs the trusted
check instead of rubber-stamping (bounded at 3 blocks per episode), and
every grant appends a forensic line to
~/.gstack/security/verify-gate-trust-grants.jsonl. 20 tests, red-first.

* fix(setup): EXIT traps chain instead of clobbering; timed-out probes reap their whole tree

The Playwright-lock trap replaced the copied-bun cleanup trap and then
cleared ALL exit handling, leaking .tmp-bun-bin on every Chromium
install; and _wait_with_deadline killed only the subshell, orphaning
the wedged node→Chromium tree it exists to escape — re-creating the
#2136 pile-up on every timed-out re-run. Traps now chain; timeouts
walk pgrep -P descendants leaves-first.

* refactor(resolvers): one source for the design-doc discovery block

The #703 repo-doc-preference bash was pasted byte-identically into
three plan-review templates and a fourth copy embedded in review.ts —
drift there means plan reviews disagree about which design doc wins.
Now a {{DESIGN_DOC_DISCOVERY}} resolver; generated output is
byte-identical, so no SKILL.md changes ride along.

* fix(ship): finish the Apple upload idempotency sentence

The durable-effect contract dropped its consequence clause mid-sentence
— the instruction for what to DO when the idempotency key already
exists (treat the upload as possibly-done, never re-run it) was
missing from the one rule governing whether a binary uploads twice.

* fix(ci): SHA-pin dependency-review; the secret gate fails closed without a report

dependency-review.yml rode mutable refs (@v4 resolves to a BRANCH on
that repo) inside the one workflow whose job is supply-chain hygiene —
now commit-pinned like its siblings, with dependabot keeping the pins
fresh. gate-secret-scan.mjs crashed with an unhandled EPIPE on
oversize diffs (the designed report.oversize branch was unreachable:
the scanner emits no JSON on refusal) — the pipe write now tolerates
early exit and a missing report is an explicit fail-closed exit 1.
Oversize + broken-scanner legs pinned.

* fix(bins): Windows-safe GIT_CEILING join; next-version probes the full default-base chain

GIT_CEILING_DIRECTORIES was joined with ':' — git on Windows splits on
';' and drive letters contain ':', silently disabling the #2144
second-layer defense there; now path.delimiter. next-version's
default-base detection only tried origin/HEAD then 'main', diverging
from the canonical 4-step chain diff-scope uses — origin/main and
origin/master probes added, pinned by fixture repos.

* fix(eval-model): kinds are a literal union, not string

Record<string,string> widened EvalModelKind to string, so a typo'd
kind only failed at runtime; as const satisfies keeps the closed set
the doc comment promises.

* test: coverage backfill from the ship review

The telemetry-strip invariant only validated the sed FALLBACK while
the live jq path went unchecked — the jq del() lists are now held to
the same every-emitted-field bar, plus a behavioral pipe-through. The
context-bill nested-skill double-count fix gets a regression pin (a
revert shipped green before). The windowsHide tripwire gains
terminal-agent-control.ts — the exact file the fix commit names. The
ios-qa revoke-by-token_id branch gets its negative case: unknown ids
revoke nothing and leave live sessions alone.

* docs: SLATE_HOST no longer cites the deleted platform-detect bin

Host detection lives in the hosts/ registry via host-config-export.ts;
the doc's known-gaps list now says so instead of pointing at a bin this
branch removed.

* test(e2e): headroom for the two plan-ceo-review budget-edge tests

Both rode their 360s runner budget at the edge (main clears at 243s of
360s), and the wave legitimately adds work to the review: the evidence
directive tells the agent to probe before claiming, and the design-doc
discovery block adds bash steps. Under concurrent in-file children the
API queuing tipped all retry attempts past the ceiling — the runner then
reports $0.00/0 turns for a timed-out child, which reads like a dead
spawn but is a healthy child killed at the deadline. 540s runner / 660s
test for these two only; verified 2/2 green at 228s and 315s.

* fix(code-intelligence): gbrain search/export are consent-gated and receipted

The Sourcebot side got this in the last round; gbrain had the same hole —
search() and export() sent repo-derived query text into a possibly-remote
DATABASE_URL with no consent check and no egress receipt, bypassing the
deny-tier veto. Both now assert consent before any bytes move, receipts
record the actual consent state (never a hardcoded true), and search
receipts carry the query's sha256. gbrain stays fail-closed: the adapter
cannot see where DATABASE_URL points, so every send requires consent.
7 new tests, red-first.

* fix(make-pdf): SVG remote refs and image-set can no longer fetch offline

<svg><image href=https://…> and <use xlink:href=…> survived the gate (only
javascript: schemes were stripped from svg hrefs), and bare-string
image-set("https://…" 1x) dodged the url()-shaped neutralizer. Remote
svg hrefs rewrite to '#' (entity-decode-aware, unclosed-svg smuggle
closed) and remote image-set args neutralize to url(#). Local fragments,
local image-set, and plain <a> links pinned intact. 12 new rows, red-first.

* fix(browse): duplicate config keys read last-wins, matching gstack-config

readGstackConfigYamlKey took the FIRST match while gstack-config's get
takes the LAST — a duplicated pair_agent or telemetry line made the two
consent surfaces disagree about what the user chose.

* fix(setup): stale Chromium-install lock self-heals

The mkdir mutex had no owner: a SIGKILL'd setup left the lock behind and
every later run exited with manual rmdir instructions. The holder pid is
recorded in the lock; a dead holder is reclaimed automatically.

* fix(setup-gbrain): the code-intelligence offer gate skips when the bin is absent

The new Step 1.7 told the agent to run gstack-code-intelligence before
the path pick — on installs predating the CLI (and hermetic E2E
children) the bin doesn't exist and setup derailed before doing any
setup. The gate now probes for the bin and reports offer:false
reason:bin-absent, with explicit instructions to proceed: the user asked
for gbrain, so set up gbrain. Never block setup on an optional gate.

* test(e2e): periodic-tier repairs from the failure triage

Each fix traces to a receipt: brain-privacy-gate staged config never
reached the hermetic child (ambient GSTACK_HOME is scrubbed) and the
operator's remote-mode gbrain suppressed the gate — both now injected
per-test; ship-idempotency threw away its evidence on the timeout path
and ran a 600s budget its own subject can exceed (now 900s, evidence
captured); auto-decide-preserved gets the same headroom its sibling
plan-ceo tests got; context-skills' hides-checks scanned bash output
where an ls legitimately names old checkpoints (final-text scope now);
design names the missing section instead of a bare count and learns the
easing/duration/micro-interaction synonyms; qa-workflow's collector
afterAll gets an explicit 60s hook timeout.

* fix(eval-harness): eng-review phase boundary fires on qid-tagged questions

The Step 0 boundary only matched two prose phrases, but plan-eng-review
may legitimately reach the review phase without either — every
per-finding AskUserQuestion then counted as pre-review and the batching
regression test read 0 questions while watching the agent ask them one
by one. The boundary now also fires on the first answered question
carrying a gstack-qid:eng-review- marker. Additive only; 119 runner
unit tests green.

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

Fork port wave 2: the release-summary entry credits Sina Matian
(time-attack/gstack) and the four absorbed community PRs. TODOS gains
three review-round follow-ups (dual-write E2E, migration runner
re-offer, gbrain-adapter op coverage).

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

* fix(eval-harness): eng-review qid boundary matches the real skill-name prefix

Live qids render as gstack-qid:plan-eng-review-<slug> ({skill}-{slug}
convention); the boundary anchored eng-review- immediately after the
colon and never matched, leaving the batching counter blind while the
transcript showed per-finding questions being asked one by one.

* fix(setup-gbrain): never ask the provider question inside /setup-gbrain

Invoking /setup-gbrain IS the provider choice. Step 1.7 now records
'select gbrain' best-effort and proceeds straight to setup; the offer
ceremony is reserved for entry points where no provider was named. On
machines where the code-intelligence CLI exists, the offer:true path
was hijacking setup into the provider ceremony and the E2E child never
reached MCP registration.

* chore: file the three documented-red periodic tests as structural-repair TODOs

Sidebar trio exercises endpoints removed on every tree; ship-idempotency's
PTY child never receives its typed command; brain-privacy-gate has never
been green anywhere. Each carries its triage receipt in the entry.

* test(e2e): setup-gbrain remote — hermetic env via opts, evidence on failure, output-scoped classifier

Three separate defects stacked on this one test: the ambient
GBRAIN_MCP_TOKEN/GSTACK_HOME/PATH mutations never reached the child
(hermetic-env scrubs them by allowlist — broken since hermetic env
landed; the child correctly stopped at Step 4c with NEEDS_CONTEXT),
failures discarded the in-memory transcript so every triage started
blind, and the wrote-findings-before-asking classifier scanned the full
event stream where the child's own Read of the skill file always
contains the review-report phrase. Env now goes via opts.env, failures
dump bash commands + final text, and the classifier scans assistant
output only. Green in 67s with all seven asserts.

* test: final coverage pass — CLI rendering, revert traps, keychain probe, gbrain doc ops

The user-directed third generation pass closes the audit's remaining
tail: the code-intelligence CLI's options/status/suggest surfaces get
behavioral coverage through the fake-shim chain; brain-context-load
gains an argv-logging trap that goes red if anyone reverts the memoized
PATH scan back to the spawn probe (receipt: simulated revert failed
exactly these tests); the darwin Keychain auth branch (#1890) gets its
first free-tier tests via a PATH-shimmed security binary; and the gbrain
add/delete/export ops are pinned (body piped byte-for-byte, receipt
sha256, stdin-EOF prompt guard, PROVIDER_UNAVAILABLE degradation) —
retiring their TODOS entry.

* test: assemble the planted PEM at runtime so the fixture never trips the prepush guard

The repo's own credential guard scans pushed diffs and correctly
blocked these fixtures: the engine flags any one-line BEGIN…END
spelling regardless of body. Header, body, and footer are now joined
at runtime, so the file and every diff of it stay clean while the
scanner under test still receives the true live shape.

* docs: update project documentation for v1.65.0.0

README gains the two wave-2 CLIs (gstack-code-intelligence,
gstack-verify-gate) in the standalone-binaries table, BROWSER.md
documents BROWSE_PERSIST_STATE next to manual state save/load,
CONTRIBUTING's CI section lists the new supply-chain gates, and
CLAUDE.md's project tree reflects lib/code-intelligence/ and the
added workflows.

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

* docs: apply cross-model doc-review fixes for v1.65.0.0

Findings from the release doc review, verified against source:
verify-gate's README row gains the actual install one-liner (setup
never registers the Stop hook; test/verify-gate.test.ts pins that)
and the 3-blocked-re-entries yield behavior; code-intelligence's row
gains the suggest subcommand and the search-side consent gate;
CONTRIBUTING scopes the SHA-pin claim to the supply-chain workflows
and widens the dependency-review trigger; BROWSER.md's restore-time
cookie drop list matches isInternalCookieDomain; CLAUDE.md's
workflows comment stops implying six workflows are all of them.

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

* docs: CHANGELOG accuracy pass — scope the SHA-pin claim, restore-time cookie filter, exact test counts

* test: env restore runs per-test, not per-suite — the leak that failed 30 strangers

gstack-memory-helpers saved HOME/GSTACK_HOME/PATH in beforeEach but
restored in afterAll, so the last beforeEach's snapshot won and a
gstack-test-engine temp dir leaked into every later file in the same
process: gstack-config read the wrong store, make-pdf's child resolved
Chromium under the temp cache, update-check and artifacts-init lost
their real homes. afterAll is now afterEach; the config and
update-check harnesses also strip GSTACK_HOME/GSTACK_STATE_ROOT from
child env as a belt.

* fix(browse): restore the #1846 start-timeout resolution the merge dropped

The v1.64.1.0 merge kept this branch's lock design in cli.ts and
silently lost main's resolveStartTimeout + late health re-check while
their test survived — ported both back in alongside the kept design.

* test: adapt main's diagnostics tests to the merged designs

cli-lock asserts typed ServerLockError (errno + lock path) instead of
the log-and-return shape the merge didn't keep, dropping only the one
duplicate of server-lock-errors coverage; the liveness tripwire exempts
error-handling.ts as the sanctioned tasklist site; snapshot and
compare-board wrappers pass the now-mandatory browser-manager arg;
background.js's test pins that the retired sidebar-command type is
rejected pre-gate with no response fields.

* chore: gitignore the gen-accessors tool's SPM build output

skill-e2e-ios-swift-build compiles the Swift package in place, leaving
.build/ (2,800+ files) and Package.resolved untracked after every
periodic run — the workspace read as ~100 dirty changes with a clean
tree. Same class as the dist/ binaries: build output, never committed.

* test(browse): subprocess budget for the polyfill suite on Windows CI

Every test here spawnSync's a node child; cold-start on the Windows
runner (AV scan, first node.exe touch) blew bun's 5s default by 7ms on
a 50ms sleep test. File-level 20s default — subprocess budget, not
assertion looseness.

* test: make the Darwin migration path and the query-timeout SKIP deterministic on Linux CI

The v1.65 migration suite relied on the host being macOS — on the
ubicloud runner the script's uname gate early-exited every test with
empty output; a Darwin uname shim in the shared setup runs the real
path everywhere (the non-Darwin test still overrides it with Linux).
The 1ms-budget brain-context test assumed 1ms is always too short; the
runner's fake gbrain answered in 0ms and no SKIP printed — the fake now
sleeps 300ms so the timeout is a certainty, while --version stays
instant for the detection assertion.

---------

Co-authored-by: Gawie van Blerk <gawievanblerk@gmail.com>
Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Shawn Reddy <19191746+Screddyice@users.noreply.github.com>
Co-authored-by: Jake Wilk <jwilk@highlinerepartners.com>
Co-authored-by: Jerry Nichols <jerrynicholsai@users.noreply.github.com>
2026-08-15 11:42:19 -07:00

1435 lines
65 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 (1000060000), generates a bearer token, writes the state
file (chmod 600), starts accepting requests. ~3 seconds.
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.
### 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 (1000060000) |
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (1000060000) |
---
## 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 |
| `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. |
### 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).
- Session state: `~/.gstack/security/session-state.json` (cross-process,
atomic).
A shield icon in the sidebar header shows the live status. 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 --json '{"max_depth":5}'
```
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 (1000060000) |
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (1000060000) |
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 1000060000) | 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
│ ├── 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)
│ ├── 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.