* test(helpers): shared skill-census helper with three explicit counts
physicalSkillFiles (symlinked dirs included, root router included),
authoredSkills (realpath-deduped, router excluded), registryEntries
(what ./setup registers: unique frontmatter names + _gstack-command).
One counting authority for the hermetic seeder, context-bill ground
truth, and the catalog-budget test — connect-chrome's dir symlink and
the root router otherwise produce three subtly different hand-rolled
censuses. Ported-wave foundation (C11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): stop the harness grading itself
findPreviousRun excluded only the file being written, by name, so every
suite compared against _partial-e2e.json — the current run's own
accumulator, relabelled with the current tier just before each flush.
That is why every block read '+$0.00, +0s, Stable run, no regressions.'
This harness has never been able to detect a regression, and reassuring
output that cannot fail is worse than none. In-progress runs are now
excluded by role, and a run with nothing to compare against says NO
BASELINE instead of claiming stability.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f3140b5245221fff7fb9411c7ec07c2ca11587b5)
* refactor(evals): shared partial-run predicate + finalized-run lookup
isPartialEval(data, filename) is the one place that decides what counts
as an in-progress accumulator (the _partial flag OR a _partial-prefixed
filename), and findLatestFinalizedRun(evalDir, tier) is the one place
that finds the newest real run — scanning the eval dir plus one level of
shards/<slug>/ subdirs, where the sharded paid runner points each
shard's collector. skill-budget-regression.test.ts's hand-rolled
findLatestRun (flag-blind: a flagged-but-renamed accumulator passed its
name check) is replaced by the shared helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b55fcf6966366fd21a8cdc46de61aab6e1b1d100)
* feat(evals): register shipped skills for hermetic PTY children
Hermetic children get a config dir that deliberately seeds no skills —
right for children that install their own, fatal for the PTY family that
TYPES /office-hours or /plan-ceo-review: claude rejects the command as
Unknown before any model turn, so the plan-family gate smokes measure
nothing. hermeticSkillsConfigDir() is a second, opt-in config dir under
the same runRoot that mirrors ./setup's registration exactly (real dir
per registry name, SKILL.md + sections/ symlinks, frontmatter-name
resolution, _gstack-command root alias), driven by the shared
skill-census so connect-chrome's dir symlink collapses the same way
setup's idempotent overwrite does.
Ported from fork commit 03c4eca2, tree walk rewritten for the upstream
layout (top-level <skill>/SKILL.md dirs, no skills/ tree). Unit tests
are new: seed shape, census parity, symlink resolution, connect-chrome
collapse, idempotence, no-API-key seed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 93dae6107b30ce453a07c2d342b60262bba6ce0b)
* feat(evals): seedSkills opt-in for PTY slash-command tests + tripwire
Wire ClaudePtyOptions.seedSkills through launchClaudePty: when set (and
hermetic, and no per-test CLAUDE_CONFIG_DIR override), the child gets
hermeticSkillsConfigDir() so typed /skill slash commands resolve instead
of dying as Unknown command before any model turn. Opted in at the three
runPlanSkill* helpers and the four direct-launch slash-command tests
(plan-design-with-ui, plan-ceo-mode-routing, autoplan-chain,
ship-idempotency).
New static tripwire (test/pty-skill-seeding-wiring.test.ts): any test
file that sends a slash command over the PTY must route through a
runPlanSkill* helper or pass seedSkills: true — an unseeded slash-command
test spends money and measures nothing. hermetic-wiring.test.ts now
blesses the repo-tree seeding path explicitly (config dir under runRoot,
symlinks into the repo checkout, never operator ~/.claude).
The CI "Register gstack skills for PTY smoke" step keeps a keep-me note:
container cross-mount symlinks defeat the TUI scanner and HOME is not
hermeticized, so the real-file copies there must survive this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 63c52269daaffb833b3105ea9b4b99be6df8fec7)
* refactor(evals): single shared paid-test-set module
test/helpers/paid-test-set.ts is now the one definition of which test
files are paid (the exact globs package.json's test:gate expands).
scripts/test-free-shards.ts derives its free/paid exclusion from it
instead of a private regex list, dropping the dead
browse/test/security-review-fullstack.test.ts pattern (file no longer
exists). The sharded paid runner derives its enumeration from the same
module, so a file added to one list can no longer silently miss the
other.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a7f36479a6a1f3656452370f5883371f3cb65623)
* feat(evals): env-driven lazy eval dir + shard-aware store and tooling
Importing eval-store no longer spawns the gstack-slug subprocess: the
module-level DEFAULT_EVAL_DIR constant is now a memoized defaultEvalDir()
resolved at collector construction. Resolution order: explicit
constructor arg, then GSTACK_EVAL_DIR, then slug detection — so the
sharded paid runner can point each shard child at its own
<evalDir>/shards/<slug>/ dir with plain env, no --preload.
Runs collected under a shards/ subdir record their slug in the eval
JSON (EvalResult.shard). findPreviousRun scans one shards/<slug>/ level
and prefers same-slug priors, so each shard baselines against its own
history instead of whichever shard flushed last. eval:list,
eval:summary, and eval:compare enumerate the same one level of shard
subdirs; eval:compare's no-arg mode also stops picking an in-progress
accumulator as the after-run.
eval-watch stays flat (documented follow-up): it tails a single dir for
live progress and gains nothing from per-shard baselines until the
runner emits a merged stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e1f53f7d9c7fe6b65877d843f2e25bd2e2d12ffd)
* feat(evals): sharded paid tier runner
scripts/test-paid-shards.ts runs the gate/periodic tier one Bun process
per test file, with an EXTERNAL wall-clock timeout that SIGKILLs the
shard's detached process group and an aggregate that distinguishes
passed / failed / timed-out / never-started — partial execution can no
longer read as a pass. Bun's native --shard/--isolate covers none of
this: no process-group kill (hung claude/codex PTY grandchildren
survive in-process isolation), no never-started taxonomy, no per-shard
env. Each shard child gets GSTACK_EVAL_DIR=<evalDir>/shards/<slug>/
(slug = test filename sans extension, stable across runs) so shard
baselines compare against their own prior runs.
Output classification lives in scripts/test-strict-output.ts (strict
exit-code derivation, incremental fail-line classifier, child signal
forwarding) so the runner and any future strict bun-test wrapper share
one implementation. Enumeration derives from the shared paid-test-set
module; tier exclusion fires only on an explicit whole-file
EVALS_TIER === '<other>' guard.
package.json gains test:gate:sharded / test:periodic:sharded, and
eval:bg:gate / eval:bg:periodic now run the sharded scripts with detach
timeouts sized to the worst case (gate: 49 shards x 30min / 4 jobs ~
6.2h -> 25200s; periodic: 59 -> 28800s).
test/paid-shards.test.ts pins enumeration, tier classification, and the
kill-and-continue property with a real busy-loop shard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5e76bd5931836257f896cedfe4e93912cb759c70)
* feat(security): hash-chained egress receipt ledger (core)
Port lib/egress-receipt from the v2 fork as TypeScript: writeReceipt
(sync, fail-closed via typed EGRESS_RECEIPT_FAILED), best-effort
writeOutcome, readLedger/listReceipts/verifyLedger, GSTACK_HOME ->
GSTACK_STATE_DIR -> ~/.gstack resolution, 0600 ledger under a 0700
security dir, and an mkdir spin lock (2.5s budget) with documented
>10s-mtime stale-lock reclaim.
Changes vs the fork:
- lastRawLine tail-reads the final 4KB instead of loading the whole
ledger, so appends stay O(1) as the file grows.
- WARN-at-size: past 25MB writeReceipt emits one self-explanatory
stderr warning per process (what the ledger is, how to inspect it,
rotation TODO); verifyLedger gains a sizeWarning field. Rotation
TODO carries the chain-genesis sketch (new generation's first record
embeds the prior file's tail hash).
bin/gstack-egress-receipt is a bun script bridging shell callers:
write|outcome subcommands, exit 3 + EGRESS_RECEIPT_FAILED on stderr on
failure; --no-payload records sha256:null for git-class ops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 619726a3d77d987a2e50151a5727b3faaaf5fc6a)
* chore(bin): delete dead brain-consumer/reader scripts
bin/gstack-brain-consumer and bin/gstack-brain-reader are byte-identical
dead scripts that POST the repo URL + a Bearer token to a /ingest-repo
endpoint gbrain removed (docs/gbrain-sync.md already documents the
removal in past tense). No live references remain; CHANGELOG mentions
are historical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 254ddc69fc5a0270fcc973e36b6a81766d835d2d)
* feat(security): shared shell receipt helpers
bin/gstack-egress-lib.sh (sourced library, gstack-gbrain-lib.sh
precedent) provides _receipted_curl and _receipted_git: write the
egress receipt BEFORE the send via gstack-egress-receipt, hand curl the
SAME payload file via --data-binary @file so the receipt hash matches
the wire bytes exactly, then append a best-effort outcome. Per-call
fail policy: 'closed' refuses the send (return 3, problem/cause/fix
message on stderr) and 'open' warns and proceeds. Payload temp files
are consumed immediately per call — no EXIT traps, since callers like
gstack-telemetry-sync own their own EXIT trap and a sourced trap would
clobber it.
Tested end-to-end against a local Bun.serve listener: receipt sha256
equals the sha256 of the bytes the listener received, fail-closed
refusal never touches the network and carries the problem/cause/fix
stderr shape, fail-open warns and proceeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 6d067dce2d4c8815dec98be551763c85a3671357)
* feat(security): receipt core shell sinks
Wire the three core bash egress sinks through gstack-egress-lib.sh:
- gstack-telemetry-sync: the batch POST now writes the payload to a
temp file, receipts those exact bytes fail-closed, and hands curl the
SAME file. On refusal nothing is sent and the cursor does not
advance, so the batch stays buffered for the next run. The HTTP
status is recorded as the receipt outcome.
- gstack-update-check: fail-open receipts (warn + proceed) on the
Supabase ping POST, both VERSION curls (via a local
_receipted_version_fetch helper that skips non-network schemes), and
git ls-remote. The ping receipt is written inside the backgrounded
subshell, so it can never block the script's exit.
- gstack-brain-sync: fail-closed git-class receipts. The push receipt
is written BEFORE the commit consumes the queue, so a refused receipt
leaves the queue intact and the next run retries the whole drain
(pinned by a new queue-intact-on-refusal test, including the
problem/cause/fix refusal message shape). The retry-path fetch and
retry push carry their own fail-closed receipts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 3c60f699acceaf1c92a218874711e05fc17dca5d)
* feat(security): receipt TS module sinks + tunnel
writeReceipt (fail-closed, sha256:null — a subprocess or SDK owns the
wire bytes) before every TS-module network-bearing operation:
- bin/gstack-gbrain-sync.ts: before the gbrain code walk that ships
repo content to the user's gbrain DB (may be remote Postgres). A
refused receipt fails the stage with status refused-egress-receipt.
- bin/gstack-memory-ingest.ts: before the gbrain batch import of
transcript pages. A refused receipt returns a system_error verdict
without spawning the import.
- browse/src/server.ts: before both ngrok.forward call sites (start-up
BROWSE_TUNNEL=1 path and the /tunnel/start endpoint). A receipt
failure lands in the existing catch that tears the tunnel listener
back down and refuses the start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5677d618a48fcd0ae2b068bf868781d90f809cb5)
* feat(design): receipted fetch for OpenAI calls
design/src/receipted-fetch.ts wraps every api.openai.com call: a
content-free egress receipt (sink design-openai, sha256 of the JSON
body — hash only, never the body) is written BEFORE the send. Polarity
is FAIL-OPEN: user-facing generation must not die because an audit log
hiccuped, so a receipt failure warns on stderr and the call proceeds.
Streams pass through untouched (response bodies returned as-is;
non-string request bodies receipted as sha256:null rather than drained
to hash).
All ten call sites converted with per-command payload classes:
generate, variants (injected fetchFn passes through), iterate (both
threaded and fresh paths), evolve (image + screenshot analysis), check,
diff, design-to-code, memory.
Unit-tested with injected fetch: receipt-before-send ordering, stream
passthrough, and fail-open on an unwritable ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c0e5ff6639414ac2fd98e8ac3affb51401746b55)
* feat(security): receipt admin scripts + user git-ops (zero exceptions)
Wire the remaining shell egress through gstack-egress-lib.sh:
- gstack-gbrain-mcp-verify: both JSON-RPC probe POSTs (initialize +
tools/list) receipted fail-closed via payload files (hash == wire
bytes). A refused receipt lands in the NETWORK class — no send.
- gstack-security-dashboard / gstack-community-dashboard: the
community-pulse GETs receipted fail-open (read-only stats must not
break over an audit hiccup).
- gstack-gbrain-supabase-provision: api_call receipted fail-closed.
Each retry attempt hands the helper a fresh copy of the body file
(the helper consumes its payload). The receipt hashes the request
body only — the PAT never reaches the ledger or any log. Refusal
exits 8 without retrying.
- git-class sha256:null receipts, fail-open: gstack-artifacts-init
(ls-remote, initial push, fetch/pull recovery, retry push),
gstack-brain-restore (staging clone, existing-repo fetch),
gstack-session-update (self-update pull).
gstack-team-init needs no wiring: every git clone in it is inside an
echoed instruction string, not an executed command.
The lib now self-locates with shell builtins only (no dirname), so
sourcing works under the whitelist-PATH test harnesses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b8c5e2055b21ab72878b3e46f8047782ee65a11c)
* test(security): egress wiring tripwire + polarity contract
Static-grep tripwire pinning the egress-receipt wiring (threat model in
the header: the ledger is forensic observability of ATTEMPTED egress,
not an exfiltration control):
- Per-sink assertions: every wired TS module imports egress-receipt and
calls writeReceipt; every wired shell sink sources
gstack-egress-lib.sh with each network op under a receipt;
ngrok-proximity check for server.ts; every design api.openai.com call
routes through receiptedFetch.
- Absence assertions: the dead brain-consumer/reader scripts stay
deleted (lstat, so a dangling symlink also fails).
- Polarity table pinned as data (fail-closed: brain-sync,
memory-ingest, gbrain-sync, telemetry-sync, ngrok, mcp-verify,
supabase-provision; fail-open: design-openai, update-check,
dashboards, git-class user ops, context-bill --exact) plus per-file
polarity spot-checks.
- NEW-SINK SCANNER with zero KNOWN_UNWIRED: sweeps bin/, lib/,
scripts/, design/src, browse/src for curl, absolute-URL fetch(, and
git remote ops (never local rev-parse/get-url; heredoc bodies and
message strings excluded) and requires every hit to be receipted or
in a REASONED exemption list where each entry carries its why.
Preamble-generated skill prose documented out-of-scope in the header.
- Shebang tripwire: no bin/gstack-* file may carry a node shebang.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ff69ceeafaf9c017d539b6ad77ff8f95b680b979)
* feat(cli): gstack-egress reader
bin/gstack-egress (bun) — the auditor's view of the receipts ledger:
- list: one row per receipt (what gstack ATTEMPTED to send), with
--since/--host/--sink filters and --json.
- verify: recompute the hash chain; exit 3 on tamper naming the first
broken line; prints the sizeWarning when the ledger passes 25MB.
- grants: what CAN leave, built on the upstream config keys only
(telemetry, artifacts_sync_mode, redact_repo_visibility,
redact_prepush_hook via gstack-config get) — each grant names its
file, key, and the exact revoke command.
CLI smoke tests spawn the real bin against a temp GSTACK_HOME,
including a broken-chain fixture asserting exit 3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9e24eca0f1069fea2ea69e7df4e9b256e93d59a3)
* feat(cli): context-bill — token bill-of-materials (stripped port)
lib/context-bill.ts, ported from the v2 fork and STRIPPED to the tiers
this repo's skills can exercise: ALWAYS-ON (per-skill frontmatter bytes
with dead-key and foreign-host-file flags), EAGER (SKILL.md + any
forced 'for every invocation' references), on-disk totals, --diff,
--budget, and --exact with the calibration table. The fork's
CONDITIONAL/TRANSITIVE/LAZY/FAST-PATH parsers understand only its
dispatcher layout and were dropped; the tier fields stay in the report
shape (empty/zero/null) so re-adding a parser is additive.
TOKEN_DIVISORS and their provenance docblock kept; --help notes
recalibration via --exact's calibration block.
Three upstream fixes over the fork:
(a) findSkillDirs treats the walk ROOT as a container — the repo root's
router SKILL.md is billed AND its children are walked (the fork
short-circuited and billed one skill); walkMd skips node_modules
and dot-directories.
(b) installed-tree layout: subdirs that are their own repo checkout
(a gstack/ clone inside ~/.claude/skills, detected by .git) are
skipped, and directory symlinks (connect-chrome) are followed with
a container-recursion cycle guard.
(c) ROUTER_KEYS widened to the upstream frontmatter contract {name,
description, version, allowed-tools, triggers, preamble-tier}.
--exact writes an egress receipt (sink context-bill-exact, host
api.anthropic.com) BEFORE any count_tokens POST; if the receipt cannot
be written the run degrades to the offline estimate with a warning —
nothing is sent unrecorded. bin/gstack-context-bill is the bun shim.
Tests: fixture-tree ledgers, the three fixes, --diff/--budget exit
codes, --exact with injected fetch (envelope subtraction, receipt
ordering, fail-open degradation), CLI smoke test, and ground truth
against THIS repo via test/helpers/skill-census.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 675c19876b87ec927b555f5f64c7f93130b3de90)
* test(catalog): aggregate discovery-surface budget with ratchet protocol
Every host loads every skill's frontmatter name + description at
discovery, every session. applyCatalogTrim in scripts/gen-skill-docs.ts
shapes each description and the 160KB per-file warn covers body size,
but nothing capped the aggregate frontmatter — the catalog could grow
one reasonable-looking description at a time. This test is that
enforcement layer.
Measures the catalog via test/helpers/skill-census.ts authoredSkills
(symlink-deduped, root router counted separately as the _gstack-command
alias line item): 53 skills + router = 4,420 bytes = 1,105
token-equivalents today, asserted <= 1,150 (~4% headroom). Per-skill
sub-cap of 260 bytes (largest today: design-consultation at 229), plus
a non-empty-description check.
Failure messages are self-service ratchets: they print the new total,
the delta, and the update protocol (bump the constant AND the
derivation comment in the same commit; trim instead of grow for
existing descriptions). Parser handles folded block scalars
(description: >-) for fork parity; import-free by design so it
survives generator refactors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c106fb36f768181b80c257e5cff1cde4f435f9c0)
* fix(browse): extension token bootstrap moves to pinned-origin POST; /health carries no token
GET /health is now liveness/status only in every mode — both token
carve-outs (headed-mode disjunct AND chrome-extension:// Origin
disjunct) are removed. Token bootstrap is POST /extension-token on the
local listener: the Origin header must be exactly
chrome-extension://<GSTACK_EXTENSION_ID> and the Host header's hostname
must parse to 127.0.0.1 or localhost (parsed via new URL, never literal
equality — Host arrives as '127.0.0.1:34567'). Wrong origin/host → 403
with no detail. The tunnel surface 404s the endpoint (not in
TUNNEL_PATHS, verified by test).
The extension ID is pinned by a new "key" field (RSA public key) in
extension/manifest.json; browse/scripts/extension-id.ts reproduces the
ID derivation (first 16 bytes of SHA-256 of the DER public key, hex
mapped 0-9a-f → a-p). The private key is not committed anywhere —
unpacked/baked-in loads only need the public key.
Extension side: background.js bootstraps and refreshes the token via
POST /extension-token (403 → disconnected state); sidepanel.js direct
connect path does the same; sidepanel-terminal.js's dead /health token
fallback (read AUTH_TOKEN/authToken keys the server never sent,
hardcoded port) is replaced with the window.gstackAuthToken path.
MIGRATION NOTE: the manifest key pins the extension ID, so existing
installs' side-panel local state (saved port, snoozes) resets once —
explained in-product via a one-time notice (flag
gstack_id_migrated_v162). After upgrading the server, restart the
browser so the old service worker stops polling for a token GET /health
no longer serves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e9a0b6847a2d17fe6656a4686b4efd0c8380eb09)
* docs: correct stale compiled-binaries claim; file three egress/eval follow-ups
CLAUDE.md's compiled-binaries section claimed browse/dist binaries are
tracked by git and appear as modified in git status — false since
64d5a3e4 (v0.11.16.0) untracked them, and actively harmful: it trained
agents to ignore dist binaries in git status. The section now states
the truth (untracked + gitignored; a dist binary in git status means
someone force-added it) and covers make-pdf/dist too.
TODOS.md gains the three follow-ups filed by the v1.62 port-wave
reviews: ledger rotation with chain-genesis records, launch-nonce
token bootstrap, and eval-watch shard-awareness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes for the v2 port wave
Review army (checklist + 5 specialists) + coverage/plan audits on the
assembled branch. Genuine correctness/security/hygiene fixes:
- test-paid-shards: strictTestExitCode now receives expectedFiles on the
real bun path, so a shard that runs fewer files than planned (harness
crash, nothing loaded) with exit 0 is no longer recorded 'passed' — the
invisible-non-execution class the runner exists to kill. Pinned by the
new test/strict-output.test.ts (also covers the chunk-boundary classifier).
- test-paid-shards: EVALS_TIER env is validated (gate|periodic) like the
--tier flag, so a typo can't self-skip every test and exit 0 green.
- package.json: test:periodic:sharded sets EVALS_ALL=1, restoring the
full-tier semantics the pre-shard script had (CI already set it; local
eval:bg:periodic silently under-measured without it).
- brain-sync.test: run() pins HOME to the temp home so gstack-artifacts-init
stops writing/clobbering the operator's real ~/.gstack-artifacts-remote.txt
every free-suite run; afterEach now also scrubs the current filename.
- egress-receipt: cap each receipt field at 512B so a serialized line always
fits the 4KB tail-read window — a longer line would make the next append
hash a truncated prior line and verifyLedger report a permanent false
TAMPER. warnLedgerSize short-circuits before statSync once fired (append
hot path).
- gstack-egress: import.meta.dir (Windows-safe) instead of new URL().pathname
so grants doesn't silently report defaults on Windows; strip control chars
from ledger-derived fields on render so a crafted receipt can't spoof the
auditor's view.
- extension/background.js + CLAUDE.md: renumber the identity-pin migration
refs v1.62 -> v1.63 (main claimed 1.62.0.0; this wave queue-advances).
- egress-receipt-wiring: pin lib/context-bill.ts unconditionally (both land
together now); drop the dead RunShardsOptions.tier field.
All fix-affected test files green; gate failures triaged as external-env
(codex/gemini CLI drift) or pre-existing (hermetic-canary fails identically
on base). Deferred polish tracked in the PR body + decision store.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v1.63.0.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: file TODO to harden plan-design-with-ui PTY detection
The v1.63 seedSkills change made this gate test execute for the first
time; it reliably times out because its terminal scraper can't parse the
(correctly-rendered) scope-gate AskUserQuestion out of a spinner-mangled
PTY buffer. Shipped skill behavior is correct — test-harness limitation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: re-slot release as v1.62.1.0 (PATCH per user)
Main claimed 1.62.0.0 while the wave was in flight; the user chose the
PATCH slot over queue-advancing MINOR. Renumbers the identity-pin
migration notice (now version-free flag name so a re-slot never orphans
an already-set flag), the CLAUDE.md /health note, the CHANGELOG heading,
and the TODOS section titles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): stop hard-requiring the literal ok) case label in gbrain-refresh guards
The extractor grepped for 'ok)' but the case label grew to
ok|timeout|thin-client) (#1964, #2051), so the whole file errored on
import — the free suite's only red for months. The extractor now matches
any label starting with ok and its alternations; all 7 guard assertions
run again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): hermetic-canary probes with ${VAR:-} so nounset shells can't fail success
The probe echoed bare $CONDUCTOR_WORKSPACE_PATH — when scrubbing WORKS
the var is unset, and under a nounset shell the echo errors, failing the
canary exactly when isolation succeeds. Defaulted expansions assert
identically under any shell. Fails identically on base; fixed here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): absorb codex/gemini CLI drift; external-service tests go periodic-tier
- codex exec gains --skip-git-repo-check: newer CLIs refuse exec in an
untrusted non-git dir (our temp skill dirs) — empirically verified.
- gemini: --skip-trust was removed in gemini-cli 0.34 (argv parse error);
dropped from the session runner and the benchmark adapter. A present-
but-unusable CLI (deprecated individual code-assist auth path) now
classifies as SKIP, not a false adapter failure; the benchmark live
smoke skips on auth/rate_limit error codes (environmental) while still
failing on timeout/unknown (the drift classes it exists to catch).
- codex-e2e, gemini-e2e, and benchmark-providers gain the canonical
whole-file EVALS_TIER === 'periodic' guard per CLAUDE.md tiering rule 3
(external service -> periodic) — the sharded gate runner now excludes
all three (gate: 45 -> 42 shards).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): parse single-logical-line AskUserQuestions in the PTY runner
When the PTY reflows a boxed AUQ, ALL options land on ONE logical line
after stripAnsi — parseNumberedOptions parsed one option per line, found
only '1.', and the >=2 check failed forever while the correct question
sat on screen (plan-design-with-ui timed out this way twice, with the
rendered scope-gate AUQ visible in both failure buffers). The cursor
line is now parsed as a stream of ascending N. tokens; DEC cursor-
visibility residue is stripped before matching; plan-design-with-ui's
budgets grow to fit observed ~6min preamble+thinking latency. Pinned by
test/pty-auq-single-line.test.ts using the real failure buffers; all 142
existing parser-consumer unit tests still green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: restore v1.63.0.0 (MINOR — user-confirmed final slot)
The wave ships new capability (egress receipts + two CLIs, sharded paid
runner, hermetic skill seeding) at ~8K lines — MINOR scale per the
scale-aware bump rules. Supersedes the brief v1.62.1.0 re-slot; the
version-free migration flag means no state churn from the renumber.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: spell out AskUserQuestion in the PTY single-line fixture
Rename test/pty-auq-single-line.test.ts to
test/pty-askuserquestion-single-line.test.ts and expand the AUQ
abbreviation in identifiers and comments. House style writes
AskUserQuestion in full in filenames, identifiers, and comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync every doc surface with the v1.63 release
/document-release audit (4-lane, all claims verified against branch code):
- README: gstack-egress + gstack-context-bill rows in the standalone-binaries
table; Privacy & Telemetry gains the receipted-egress bullet (attempted-
egress framing per the shipped threat model).
- ARCHITECTURE: /health is liveness-only, POST /extension-token endpoint row
+ bootstrap mechanics paragraph; new Egress receipt ledger subsection under
Security model; eval persistence covers the sharded runner, GSTACK_EVAL_DIR,
and the finalized-run baseline rule.
- CLAUDE.md: sharded test scripts in Commands; sharded semantics in the
detached-evals section; PTY skill seeding in the hermetic section; egress
invariant block beside the other server-egress invariants; catalog-budget
ceiling beside the 160KB token ceiling; project-tree entries for
lib/egress-receipt.ts, lib/context-bill.ts, scripts/test-paid-shards.ts.
- CONTRIBUTING: seedSkills + live-tree seeding in the hermetic paragraph;
sharded runner in detached runs; catalog-budget in the Tier 1 list.
- BROWSER: extension token bootstrap section, tunnel egress receipts section,
identity-pin migration note in manual install.
- REMOTE_BROWSER_ACCESS: tunnel-start receipt bullet in the security model.
- gbrain docs: /sync-gbrain + brain-sync egress-receipt behavior documented;
dead consumer-token instructions removed (consumer machinery deleted this
release); new fail-closed refusal added to the error catalog.
- CHANGELOG: measured-vs-ceiling catalog numbers, contributor notes for the
external-service tier move and the PTY single-line AskUserQuestion parser,
release date.
- TODOS: /health token-distribution TODO resolved by this release, removed;
port-wave follow-up sections re-labeled to the shipped version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sweep drift that predates this release
Surfaced by the /document-release audit; every fix verified against the
current binaries:
- gstack-brain-init was replaced by gstack-artifacts-init in v1.27.0.0
(hard-delete, no compat shim), but README, USING_GBRAIN_WITH_GSTACK,
docs/gbrain-sync.md, and docs/gbrain-sync-errors.md still instructed
users to run it — command-not-found on every follow. Same sweep updates
~/.gstack-brain-remote.txt to the canonical ~/.gstack-artifacts-remote.txt
(legacy name still honored on restore, noted where users copy the file).
- gbrain-sync-errors.md headings re-matched to the literal messages the
binaries print today (the doc's whole value is grep-by-exact-message):
'gstack-artifacts-init: ~/.gstack/ is already a git repo pointing at:',
'Remote not reachable via SSH:', 'Failed to create or find ...'. The
already-a-repo fix now leads with the command's own set-url suggestion.
- docs/gbrain-sync.md 'Under the hood' linked a plan file that does not
exist in the repo; replaced with the decisions themselves.
- SIDEBAR_MESSAGE_FLOW startup timeline: /pty-session responds with
{terminalPort, sessionId, attachToken, leaseExpiresAt} (v1.44 shape,
verified at browse/src/server.ts:1860), not the retired
{terminalPort, ptySessionToken} pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fold the Codex accuracy review of the release docs
Six findings, all verified against source before fixing:
1. 'Every send writes a receipt' overclaimed — fail-open sinks proceed with
a stderr warning when the receipt write fails, so a fail-open send can go
unrecorded (lib/egress-receipt.ts:8-14). Descriptive prose now says so;
the receipted framing keeps 'attempted'.
2. 'Receipts hash the request body' is wrong for subprocess-owned sends —
git pushes record sha256: null (lib/egress-receipt.ts:71).
3. 'grants shows every consent in force' overclaimed — it reports the four
standing config settings (bin/gstack-egress:139-181). Reworded in
README, ARCHITECTURE, and the CHANGELOG entry.
4. 'Zero-exception scanner' vs reality: the new-sink scanner carries a
reasoned SCANNER_EXEMPT list (user-directed fetches, probes, instruction
strings, skill prose). CLAUDE.md now names it.
5. Error-catalog cause/fix for the receipt refusal: the writer mkdirs the
ledger dir itself, so 'missing' isn't a cause and bare chmod fails when
it is absent — cause reworded, fix is mkdir -p && chmod.
6. gbrain-sync first-run steps described the retired binary's behavior:
default repo is gstack-artifacts-$USER, and init PRINTS the gbrain
hookup command (never auto-executes; bin/gstack-artifacts-init:384-419).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
64 KiB
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
# 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
- What it is
- The productivity loop —
/scrape+/skillify - Architecture
- Command reference
- Snapshot system + ref-based selection
- Browser-skills runtime
- Domain-skills (per-site agent notes)
- Real-browser mode (
$B connect) — including--headed+--proxy+--navigate(v1.28.0.0) - Side Panel + sidebar agent
- Pair-agent — remote agents over an ngrok tunnel
- Authentication + tokens
- Prompt-injection security stack (L1–L6)
- Screenshots, PDFs, visual inspection
- Local HTML —
goto file://vsload-html - Batch endpoint
- Console, network, dialog capture
- JS execution —
js+eval - Tabs, frames, state, watch, inbox
- CDP escape hatch + CSS inspector
- Performance + scale
- Multi-workspace isolation
- Environment variables
- Source map
- Development + testing
- Cross-references
- 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.
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,/benchmarkuse 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:
- Match path (~200ms) — agent runs
$B skill list, semantically matches the intent against each skill'striggers:array +description+host, and runs$B skill run <name>if a confident match exists. - 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. - Mutating-intent refusal — verbs like submit, click, fill route
to
/automate(Phase 2b, P0 inTODOS.md)./scrapeis 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
/scraperesult. Refuses with one specific message if cold. No silent synthesis from chat fragments. - D2 — Synthesis input slice. Extracts ONLY the final-attempt
$Bcalls 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 testagainst the temp dir, and only renames into the final tier path on test pass + user approval. Test fail or rejection:rm -rfthe 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
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
- First call. CLI checks
<project>/.gstack/browse.jsonfor a running server. None found — it spawnsbun run browse/src/server.tsin the background. Daemon launches headless Chromium via Playwright, picks a random port (10000–60000), generates a bearer token, writes the state file (chmod 600), starts accepting requests. ~3 seconds. - Subsequent calls. CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip.
- Idle shutdown. After 30 minutes of no commands, daemon shuts down and cleans up the state file. Next call restarts it.
- 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 (10000–60000) |
/code/project-b |
/code/project-b/.gstack/browse.json |
random (10000–60000) |
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
page.locator(scope).ariaSnapshot()returns a YAML-like accessibility tree.- The snapshot parser assigns refs (
@e1,@e2, ...) to each element. - For each ref, it builds a Playwright
Locator(usinggetByRole+ nth-child). - The ref→Locator map is stored on
BrowserManager. - Later commands like
click @e3look up the Locator and calllocator.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-Dcall, 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 withcursor:pointer,onclick,tabindex>=0) usingpage.evaluate. Assigns@c1,@c2... refs with deterministicnth-childCSS 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:
$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)— atomicfs.renameSyncinto the final tier path. Refuses to follow symlinked staging dirs (lstatcheck), refuses to clobber existing skills, runsrealpathdiscipline on the tier root.discardStaged(stagedDir)—rm -rfthe 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
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:
domain-skill save <host>— agent writes a note about the site (e.g., "GitHub: PR creation needs--draftflag for non-staff", "X.com: timeline uses cursor pagination, not page numbers"). Default state: quarantined.- After N=3 successful uses without the L4 prompt-injection classifier flagging the note, it auto-promotes to active.
domain-skill promote-to-global <host>lifts it to the global tier (machine-wide, all projects).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.
$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:
# 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):
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
/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 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. Every layer runs synchronously on
every user message and every tool output that could carry untrusted content
(Read, Glob, Grep, WebFetch, page text from $B).
| Layer | Module | Lives in |
|---|---|---|
| L1 Datamarking | content-security.ts |
both server + sidebar agent |
| L2 Hidden-element strip | content-security.ts |
both |
| L3 ARIA + URL blocklist + envelope wrapping | content-security.ts |
both |
| L4 TestSavantAI ML classifier (22MB ONNX) | security-classifier.ts |
sidebar-agent only* |
| L4b Claude Haiku transcript check | security-classifier.ts |
sidebar-agent only |
| L5 Canary token (session-exfil detection) | security.ts |
both — inject in compiled, check in agent |
L6 combineVerdict ensemble |
security.ts |
both |
* 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 L1–L3, L5, L6 only.
Thresholds
BLOCK: 0.85— single-layer score that would cause BLOCK if cross-confirmedWARN: 0.75— cross-confirm threshold. When L4 AND L4b both >= 0.75 → BLOCKLOG_ONLY: 0.40— gates transcript classifier (skip Haiku when all layers < 0.40)SOLO_CONTENT_BLOCK: 0.92— single-layer threshold for label-less content classifiers
Ensemble rule
BLOCK only when the ML content classifier AND the transcript classifier both report >= WARN. Single-layer high confidence degrades to WARN — this is the Stack Overflow instruction-writing FP mitigation. Canary leak always BLOCKs (deterministic).
Env knobs
GSTACK_SECURITY_OFF=1— emergency kill switch. Classifier stays off even if warmed. Canary is still injected; just the ML scan is skipped.GSTACK_SECURITY_ENSEMBLE=deberta— opt-in DeBERTa-v3 ensemble. Adds ProtectAI DeBERTa-v3-base-injection-onnx as L4c classifier. 721MB first-run download. With ensemble enabled, BLOCK requires 2-of-3 ML classifiers agreeing at >= WARN.- Classifier model cache:
~/.gstack/models/testsavant-small/(112MB, first run only) plus~/.gstack/models/deberta-v3-injection/(721MB, only when ensemble enabled). - 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,
1–3 cap):
$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:
$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.
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:
$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
$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
$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
$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).
Watch
$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
$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.
$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
$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
$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,000–40,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 (10000–60000) |
/code/project-b |
/code/project-b/.gstack/browse.json |
random (10000–60000) |
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 10000–60000) | 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_SECURITY_ENSEMBLE |
unset | Set to deberta for 3-classifier ensemble (721MB download) |
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 (TestSavant + optional DeBERTa ensemble)
│ ├── 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 v1.0+
- Playwright's Chromium (installed automatically by
bun install)
Quick start
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:
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
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
- Add the handler in
read-commands.ts(non-mutating) orwrite-commands.ts(mutating), ormeta-commands.ts(server / lifecycle). - Register the route in
server.ts. - Add the entry to
COMMAND_DESCRIPTIONSinbrowse/src/commands.ts(with a cleardescriptionandusage— thegen-skill-docsvalidation suite enforces no|characters indescription). - Add a test case in
browse/test/commands.test.tswith an HTML fixture if needed. - Run
bun testto verify. - Run
bun run buildto compile. - Run
bun run gen:skill-docsto 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:
cd ~/.claude/skills/gstack
git fetch origin && git reset --hard origin/main
bun run build
Or copy the binary directly:
cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse
Cross-references
ARCHITECTURE.md— system-level architecture, dual-listener tunnel design, prompt-injection defense threat modelCLAUDE.md— project-level instructions, sidebar architecture notes, security-stack constraintsdocs/REMOTE_BROWSER_ACCESS.md— operator guide for/pair-agent(setup keys, scoped tokens, denial log)docs/designs/BROWSER_SKILLS_V1.md— design doc for browser-skills runtime (Phase 1 + 2a + roadmap)scrape/SKILL.md—/scrapeskill: match-or-prototype data extractionskillify/SKILL.md—/skillifyskill: codify last/scrapeinto permanent skillTODOS.md—/automate(Phase 2b P0), Phase 3 resolver injection, Phase 4 eval + sandbox
Acknowledgments
The browser automation layer is built on Playwright
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
(112MB ONNX), and the optional ensemble layer uses
ProtectAI/deberta-v3-base-prompt-injection-v2
(721MB ONNX) — both 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.