mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-22 14:07:14 +02:00
* fix(plan-tune): reject never-ask on one-way ids at --write --check already ignored those prefs; --write still stored them and --stats counted them as a working NEVER_ASK. Refuse the write and count leftover on-disk prefs as INERT_ONE_WAY. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: gstack-config get returns "" with exit 0 for keys that have no default Skill preambles read configuration with VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>") and that fallback only fires on a non-zero exit. lookup_default ended in a catch-all that echoed "" and returned 0, so for any key missing from the table VAR came back empty and the default written right there in the preamble was unreachable. The skill then branched on a value it never specified: "skip entirely if QUESTION_TUNING is false", reached with QUESTION_TUNING="". Four keys that skills actually read had no entry and took that path: question_tuning -> callers assume "false" repo_mode -> callers assume "unknown" team_mode -> callers assume "false" transcript_ingest_mode -> callers assume "off" Each default above is the value the call sites already substitute in their own `|| echo` fallback, so this only makes reachable what was already intended. The catch-all now returns non-zero. That is deliberately scoped to the unknown-key arm alone: keys whose default is intentionally empty still exit 0, because "" is their real answer and their callers depend on it -- cross_project_learnings ("unset triggers the first-time prompt"), redact_repo_visibility ("empty falls through to gh/glab detection"), salience_allowlist, user_slug_at_*. Making every empty answer an error would have broken those. test/gstack-config-defaults.test.ts pins the class rather than the four instances: it parses the case arms and asserts every `gstack-config get <key>` site in the tree is covered, so adding a read without a default fails CI. It also pins the exit-code contract in both directions. Verified failing against the pre-fix script, where it names exactly those four keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(redact): a typo'd subcommand no longer exits 0 having done nothing main() recognised exactly two subcommands and let everything else fall through to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so: $ gstack-redact install-prepush-hooks # plural typo gstack-redact scan — repo UNKNOWN (no findings) $ echo $? 0 No hook was installed, and the operator has every reason to believe the credential guard is armed. A guard that silently no-ops must never exit 0. Two smaller faults in the same dispatch, both of which lead people here: - There was no --help handler, so `gstack-redact --help` fell through to the scanner. Piping a credential to it scanned the secret and exited 3. - With no piped input and no --from-file, readInput() blocks on readSync(fd 0) until an EOF that an interactive terminal never sends. That prints nothing at all, so it reads as a hang rather than as "this is a filter, feed it". Now: --help/-h/help prints usage and exits 0; an unrecognised positional prints the offender and exits 1; a TTY with nothing piped in prints usage instead of blocking. "scan" stays accepted, because the human output header reads "gstack-redact scan — repo …" and that is what people type. Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH findings and callers gate dispatch on them, so a usage error exiting 2 would be read as "medium findings — prompt the user". A test pins that. Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(browse): one ambiguous ref no longer kills the whole annotated screenshot `snapshot -a` exits 1 with "Selector matched multiple elements" on most real pages, so /qa, /canary and /land-and-deploy silently produce reports whose screenshots do not exist. Plain `screenshot <path>` is unaffected. Refs are built as getByRole(role, {name}) and disambiguated with .nth() when role+name repeats. That disambiguation cannot fire for a node with NO accessible name: the locator degrades to getByRole(role) with no name filter, and the count driving .nth() is taken from the FILTERED aria snapshot while getByRole matches the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are correctly unnamed per ARIA, so this is the common case rather than an edge case. boundingBox() then hits Playwright strict mode, and the catch allowlisted only timeout/closed/Target/Execution-context messages — so the strict-mode error was re-thrown and aborted every remaining annotation. Two changes: - `.first()` before boundingBox(), so an ambiguous ref draws a box on its first match instead of aborting. The heatmap path below has always tolerated this via a bare `catch {}`; annotate was the only path that could be killed outright. - the catch no longer re-throws on unrecognised messages. A box we cannot measure is a box we do not draw, never a reason to lose the rest of the page. Set BROWSE_DEBUG to see what was skipped. Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which reads as "screenshots are broken" rather than "you forgot a flag". It now warns and points at `browse screenshot <path>`. Verified by rebuilding both ways against the same page with 51 refs present: before — "Selector matched multiple elements", no file written after — exit 0, 229KB PNG Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(version-bump): missing or empty VERSION no longer repairs a fabricated 0.0.0.0 into package.json repair now fails with exit 2 when the VERSION file is absent or empty instead of folding to DEFAULT ("0.0.0.0") — which passed VERSION_RE and regressed package.json below where it started. classify gains an additive versionFileExists field so /ship can tell a real 0.0.0.0 from a fabricated one. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2600 (repair half; the path-configurability half landed in v1.67 via #2531). Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(memory-ingest): --probe counts post-attribution, through the same gate --bulk uses probeMode previously stat'd every walked file, so setup-gbrain gated its silent bulk ingest on pre-filter counts that the write path would never ingest (#2394). The attribution decision now lives in ONE shared gate (sessionIsAttributable — cheap-parse: cwd extraction + memoized resolveGitRemote, never a full page build) used by BOTH probeMode and preparePages, so the two stages' post-attribution counts are structurally identical. ProbeReport gains skipped_unattributed; the probe prints what it excluded and --include-unattributed restores raw counts. The parity is pinned at the prepare stage (probe post-attribution == transcripts reaching import), deliberately NOT == final written. Re-derived from PR #2612 under the generated-file screening rule; the shared-gate design and the remote memo are additions from the plan review. Fixes #2394. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(browse): allow CPU and network throttling for performance measurement Adds Emulation.setCPUThrottlingRate and Network.emulateNetworkConditions to CDP_ALLOWLIST. Motivation: diagnosing a real "uploads take 1-2 minutes" report, the only machine available was a fast developer workstation. Client-side processing measured 1.4s where the user experienced minutes, so the conclusion had to be reached arithmetically rather than observed. Throttling would have let the measurement reproduce the reporter's conditions directly. Both fit the existing posture rather than widening it: - Emulation already allows setDeviceMetricsOverride, clearDeviceMetricsOverride and setUserAgentOverride, which are equally mutating and scoped to the tab. - Neither method reads page content. setCPUThrottlingRate affects only timing; emulateNetworkConditions constrains traffic rather than inspecting it, so no request bodies, headers or cookies are exposed. Both are output: 'trusted' because they return no page-derived data. scope 'tab' for both, matching the surrounding Emulation entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(session-update): lock pidfile records the live holder; hard TTL bounds every wedge (#2613) echo $$ inside the backgrounded subshell recorded the PARENT hook's PID — which exits immediately — so every subsequent session judged the lock stale and rm -rf'd a LIVE holder's lock, letting concurrent updaters run over each other. The pidfile now records ${BASHPID:-$(sh -c 'echo $PPID')} (macOS bash 3.2 has no BASHPID; the sh child's PPID is exactly this subshell). Staleness is now two independent detectors: PID liveness (as before, but against the real holder), and a 30-minute hard TTL on the heartbeat mtime — reclaimed regardless of kill -0, so a recycled PID or hung holder can't wedge the lock forever. The holder touches the pidfile after the pull and after setup, so a legitimately-slow run keeps itself alive. Empty and missing pidfiles are respected inside the TTL window (the mkdir→echo race) and reclaimed past it. Fixes #2613. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(browse): explicit windowsHide on every Bun.spawn site + census tripwire (#2575 residual) Bun.spawn sites were structurally outside the windowsHide census (it swept child_process bindings only). The runtime was already safe — native Bun hides consoles by default and bun-polyfill.cjs defaults windowsHide !== false since #2523/#2539 — but implicit defaults are exactly what regress silently. Every Bun.spawn/spawnSync in browse/src now carries the explicit flag (harmless on unix-only sites like Xvfb/xattr/open), and a second SWEEP in windows-spawn-hide.test.ts fails CI on any new flagless Bun.spawn site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): brain worktree advances on the daily sync — no more silently stale brains (#2516) The daily pull refreshed only ~/.gstack itself, never the detached worktree at ~/.gstack-brain-worktree that gbrain actually indexes — so after setup the brain served stale pages forever unless setup-gbrain/sync-gbrain happened to run. brain-sync --once now advances the worktree once per 24h behind an ATTEMPT stamp (.brain-worktree-last-advance — a persistently-failing advance warns once a day, not at every skill boundary), inside the existing run lock and before any ingest step touches the worktree. The new gstack-gbrain-source-wireup --advance-only is built for the unattended cadence: git-only (no gbrain prereqs), pins every operation to the managed worktree (refuses paths that are not worktrees of the artifacts repo), refuses dirty worktrees, and never runs the force-remove recovery — a cron path must not be able to delete local changes. A static pin keeps the force-remove out. docs/gbrain-sync.md stops overclaiming the old cadence. Fixes #2516. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(memory-ingest): honor the per-remote deny/read-only trust policy (#2392) Transcript ingest now respects the same trust store as code import — the gate existed only in gstack-gbrain-sync's runCodeImport, so memory-ingest happily ingested transcripts from deny-listed repos. preparePages filters prepared transcript pages through ONE batch policy lookup (new 'get --batch' verb on bin/gstack-gbrain-repo-policy — the script owns URL normalization; the client adds repoPolicyTierBatch, one spawn for all distinct remotes, so large corpora never pay a 10s-timeout subprocess per remote). Outcomes match code-import semantics: read-only → clean skip (skipped_policy_readonly), deny → counted refusal (skipped_policy_deny), corrupted/unreadable store → HARD ERROR before any write (state, staging, egress receipt, and import all untouched) with the recovery command named — policy corruption must never read as successful ingestion. Artifacts are never policy-filtered (their git_remote is a project slug, not a remote). Fixes #2392. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(config): repo_mode keeps its empty no-default semantics (#2611 follow-up) The ported defaults table synthesized repo_mode → "unknown", but EMPTY is load-bearing for that key: gstack-repo-mode treats any non-empty answer as a user override and skips its own repo classification — the synthesized default turned the classifier into dead code (REPO_MODE=unknown everywhere; caught by test/gstack-repo-mode.test.ts via the wave's cross-agent blame protocol). repo_mode joins the empty-is-real carve-outs (empty output, exit 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): consent before killing a healthy headless daemon The pair-agent headed switch spawned 'connect --force-restart' unconditionally — auto-killing a live headless daemon (open tabs, cookies, logins) in direct contradiction of the iron rule it sits beside ('only an explicit --force-restart may kill a live daemon'). The CLI now captures daemon liveness BEFORE ensureServer (which can itself boot a fresh daemon) and relaunches only when the user passed --force-restart to pair-agent; otherwise it prints the tab count and continues against the existing daemon. The /pair-agent skill gains a matching one-way-door consent question (template half rides the wave's template block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain-status): MCP scoping is per-project, and project-local beats user scope hasRemoteOnlyGbrainMcp scanned EVERY project's mcpServers in ~/.claude.json, so one project's remote gbrain registration reclassified broken local engines as thin-client machine-wide. It now reads user scope plus only the cwd's nearest-ancestor project key. The precedence itself was verified empirically and hermetically (fake HOME + CLAUDE_CONFIG_DIR fixtures, claude 2.1.233): with both scopes defining gbrain, 'claude mcp get gbrain' reports Scope: Local config — PROJECT-LOCAL WINS. Both in-repo consumers assumed the opposite; brain-cache's endpoint resolution flips to nearest-ancestor-project-first, and the stale user-first pin in brain-cache-roundtrip now pins the verified precedence. (The user-first jq in the brain-sync preamble resolver gets the same swap in the template block.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slug): gstack-slug matches remote-slug's owner-repo canonical form (live misfile bug) Found live during this wave's CEO review: bin/gstack-slug emitted SLUG=garrytan for this garrytan/gstack worktree while remote-slug correctly gave garrytan-gstack — decisions, timeline, ceo-plans, and learnings were filing into the wrong project store (observed polluting Context Recovery with another repo's decisions). Root cause: a stray empty ~/.git directory made the walk-up crown $HOME as the outermost project root; the remote lookup ran only against that root, failed silently, and the basename fallback cached 'garrytan' sticky. NOT worktree-specific — any strong marker on a non-repo ancestor triggered it. Fix: the walk now finds the outermost ancestor whose .git actually resolves an origin remote and derives owner-repo with remote-slug's byte-identical parse; marker-only ancestors keep anchoring the basename fallback but can no longer shadow a real remote. A new cache self-heal recomputes the poisoned shape (cached == basename of a marker root while a remote-bearing repo exists below), preserving legit #2212 stickiness. Nested-repo walk-up, no-remote and non-git fallbacks, and the SLUG=/BRANCH= eval contract are unchanged, pinned by a 10-case parity suite. Store migration for pre-fix data is tracked in TODOS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(brain-sync): per-record spool dir — the enqueue/drain race dies structurally Producers appended lines to .brain-queue.jsonl while the drain re-read and os.replace'd it; the in-code comment admitted a lockless append between the re-read and the replace was lost. Locks and rename-rotation designs were both reviewed and rejected (each retained a tail race); the shipped design is a maildir-style spool: one FILE per record in .brain-queue.d/ (tmp + atomic rename), the drain snapshots filenames, processes, and deletes exactly what it snapshotted. Writer and drainer never share an inode — nothing to race. Semantics: at-least-once (a crash between process and unlink re-drains; downstream content-hash dedup absorbs duplicates); retained (privacy-held) records keep their files; unparseable records are kept + warned, never destroyed. Legacy .brain-queue.jsonl migrates atomically on the next drain (crash-leftover .migrating files recovered too); status/drop-queue count both surfaces; discover-new writes spool records and advances its cursor per-record-written. The preamble's queue-depth line switches to spool count in this wave's template block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin-context): native slug fallback walks up like bash gstack-slug slugFromEnvironment derived the slug from the INNERMOST repo's origin while bash gstack-slug walks to the outermost project root — nested/vendored repos split their stores across the bash/native boundary (win32 hits the native path constantly). The native fallback now ports _outermost_project_root faithfully (strong/weak markers, outermost-strong-wins, 64-depth cap, fixed-point termination) plus the full resolution order: env override → walk-up → sticky cache with the #1125 self-heal → remote get-url → basename. Twelve mirrored scenarios drive BOTH implementations against the same fixtures and pin identical slugs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): git fallback queries the live remote, never mutates, and keeps 3-digit width The degraded path counted every remote-tracking ref on every remote — stale experiment branches and second remotes inflated version allocation, and a failed base read flipped 3-digit repos to 4-digit slots. Now: ls-remote --heads origin first (GIT_TERMINAL_PROMPT=0, 5s timeout, zero local ref mutation); on failure, local refs/remotes/origin ONLY with an explicit stale-refs warning; a failed base read zeroes at the LOCAL version file's width so a 3-digit repo allocates 0.0.1, not 0.0.1.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(setup): hooks register the global-install path and re-point stale ones Registering hooks from a dev worktree baked that worktree's absolute path into settings.json — deleting the worktree left a dead hook erroring on every session stop, and the presence-only dedup (list-sources | grep) could never re-point it. setup's hook paths now route through _hook_install_path (global install preferred, source dir fallback), and the new ensure-event verb on gstack-settings-hook compares the registered command payload against canonical: identical → no write, different → single atomic replacement (never zero or two registrations). The plan-tune hooks had the same stale pattern and get the same fix without re-triggering their consent prompt. Also hardened: bun 1.3.13 turns an uncaught sync fs error in bun -e into a SILENT exit 0 — the registrar's write path now catches, prints, and exits 1, so a failed update can never report fake-green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): learnings capture is unconditional at completion (#2402) 43 of 44 learnings entries came from explicit /learn — the completion-status prose read 'if you discovered a durable project quirk... log it', which models treated as optional. The step now ALWAYS runs: review the session for durable learnings, log each one, and state 'No durable learnings this session' explicitly when the review comes up empty — an empty result, never a skipped step. Re-derived from PR #2612 under the generated-file screening rule. Fixes #2402. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scrape): untrusted-content warning on the page-fetching skills (#2441) /scrape and /skillify consumed page content with zero injection guidance — the CHANGELOG claimed coverage the skills didn't have. The warning now lives in ONE exported const (UNTRUSTED_CONTENT_WARNING in resolvers/browse.ts), embedded in the browse COMMAND_REFERENCE as before AND injected standalone into both skills via the new {{UNTRUSTED_CONTENT_WARNING}} token — single source, wording can never drift between surfaces. Re-derived from PR #2612 under the generated-file screening rule. (Structural isolation for skillify-generated code is tracked as its own TODO.) Fixes #2441. Contributed by @Lockyer228 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): checklist paths resolve from the installed skill root (#2518) /review Step 2 read .claude/skills/review/checklist.md — a path relative to the TARGET repo, which only resolves in gstack's own checkout. Every checklist/greptile-triage/TODOS-format reference (six across five templates — two more than the issue named, same class) now uses the installed-root form ~/.claude/skills/gstack/review/... that the templates' other references already use. The install-root class itself (non-default install dirs) is #1882, deliberately its own PR. Fixes #2518. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pair-agent): one-way-door consent question before a daemon relaunch (template half) The skill flow now checks daemon liveness before Step 4 and asks an explicit one-way-door question (tabs/cookies/logins are lost) before passing --force-restart — never proceeding on a vague reply. Pairs with the CLI-half commit that stopped pair-agent auto-killing live daemons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(codex): resume does not amortize the ~21K session prelude (#2387) Measured (#2387): every codex exec call pays Codex's session prelude, and a resumed call came in slightly ABOVE a fresh one — resume buys continuity, never token savings. The skill now says so where the resume flow lives: prefer one codex call per skill, batch questions into it. Fixes #2387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(upgrade): fast-forward first; reset --hard only behind a proved-safe gate (#2517) /gstack-upgrade went straight to stash + reset --hard origin/main. Now it tries git pull --ff-only --autostash first (the same policy session-update's auto-upgrade uses). The destructive fallback runs unprompted ONLY when both git status --porcelain AND git rev-list origin/main..HEAD are empty — a clean tree with unpushed local commits is NOT safe, reset destroys them. Anything else requires an explicit one-way-door confirmation that lists every dirty file and unpushed commit being discarded. Fixes #2517. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(preamble): brain-sync block counts the spool queue and resolves MCP project-first Two resolver halves deferred from earlier wave commits: the queue-depth line counts .brain-queue.d/*.json spool records (plus legacy lines until the drain migrates them), and GBRAIN_MCP_ENTRY_JQ swaps its operands to nearest-ancestor-project-first — matching the empirically verified Claude Code precedence (project-local beats user scope) instead of the backwards user-first assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + golden fixtures (single regen for the template block) Pure generator output for the six template/resolver commits above (learnings capture, untrusted-content warning, review paths, pair-agent consent, codex resume note, upgrade ff-only, brain-sync block) — bun run gen:skill-docs + --host codex + --host factory, with the three ship golden fixtures refreshed per the documented procedure. The three sidecar-path pins in gen-skill-docs.test.ts move to the new installed-root/$GSTACK_ROOT contract (#2518). Restores template freshness; full suite green from here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: TODOS.md — strike the six wave-fixed residuals, add two follow-ups The v1.67 adversarial-review residuals section shrinks to the one item the wave couldn't reach (iOS tap routing — needs real-device verification). New entries: skillify structural isolation (a prose warning is not a boundary for page-derived generated code) and the slug store migration (pre-fix sessions on stray-marker machines filed data under the degraded slug; post-fix reads go to the correct store, so history needs a merge/alias). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align cross-cutting pins with the wave's contracts Three suites pinned pre-wave behavior: browse's gstack-config test asserted the old unknown-key ''/exit-0 shape (#2611 made it exit 1); the Windows-paths suite pinned O_APPEND enqueue atomicity (the spool design satisfies the same invariant via tmp + os.replace, one file per record — pinned in its new form); and nine carve-guard skeleton ceilings absorbed the #2402 unconditional-learnings prose (~450B per skill), bumped with measured values per the guard's own protocol. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: re-anchor the referenced-path scanner self-check to the gstack-rooted review refs The self-check pinned the review checklist as a class-1 alias-relative ref; #2518 moved those refs to the installed gstack root (class 2). The guard now proves the scanner sees them in their new class, so the class-2 assertion can't go vacuous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin the wave's prose-tier behaviors (ship coverage-audit gap closure) The coverage audit found one regression-shaped gap: nothing pinned that the upgrade template's ff-only pull precedes the gated reset --hard (#2517) — a future template edit reverting to reset-first would fail nothing. Pinned: the ordering, the FF_OK gate, and the unpushed-commits check. Also pinned the two minor gaps: the {{UNTRUSTED_CONTENT_WARNING}} injection points in scrape/skillify (#2441) and brain-uninstall's spool-dir cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review round — 8 auto-fixes + 8 accepted findings hardened The ship review army (4 specialists + red-team + checklist, 29 findings) produced 8 mechanical auto-fixes and 11 decisions; the accepted set: - win32 slug parity completed: lib/bin-context.ts gains the remote-first outermost walk + degraded-cache self-heal the bash side got this wave — the two implementations now agree on the stray-marker live-bug shape, pinned by shared fixtures (multi-specialist 9/10 finding). - probe honors the plan's bounded-read decision: 256KB prefix, extraction semantics mirrored from parseTranscriptJsonl so probe/prepare can never diverge on the same file (>1MB transcript test). - policy normalize parity: bash normalize() now matches canonicalizeRemote on .git/-trailing and uppercase-.GIT shapes (7-shape corpus pinned two ways) — a deny for those shapes could previously slip the transcript gate. - session-update reclaim is TOCTOU-safe (atomic mv-aside on both branches). - settings-hook: unparseable settings.json errors instead of being replaced with {}; ensure-event keys on (event, source) so matcher changes update in place — never zero or two registrations. - dot-only slug guard at both parse sites (hostile 'url = ..' can't escape projects/); enqueue tmp-file janitor (1h TTL, inside the drain lock); brain-sync .migrating never clobbered; drop-queue/status count .migrating; snapshot -o warning correct + surfaced in diff mode; version-bump test order-dependence removed; uninstall clears the advance stamp. Deferred with record: slug heal-probe cost sentinel (P3 TODO), FF_OK conflation (noted, misdiagnosis-only). 270 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial round — the P0 finalize fail-safe and 12 hardened findings Three adversarial passes (Claude fresh-context, Codex chaos, Codex structured with P1 gate) on the full wave diff. Multi-source findings, all fixed: - P0: finalize_queue is now explicit-delete-only — a record is unlinked ONLY when classification proves it staged or dropped; a classifier crash, a missing class file, or a malformed pulled .brain-privacy-map.json (which previously nuked the whole snapshotted queue, remotely triggerable) now retains everything, warns, and re-drains next run. load_privacy_map treats corrupt maps as retain-all, never as empty. - next-version cannot silently drop a live claim: unreadable advertised refs get a targeted --depth=1 fetch + retry; still-unreadable claims surface as UNKNOWN warnings instead of duplicate-version silence. - session-update lock: ownership-checked EXIT trap (a TTL-reclaimed holder can no longer delete the new holder's lock) + a 5-min background heartbeat so a legitimately-slow pull/setup is never reclaimed while alive. - ensure-event collapses ALL same-(event,source) duplicates to one canonical entry; unique per-process tmp path; setup call sites surface (not swallow) the hardened refusals. - memory-ingest: --limit counts only policy-permitted pages (denied records no longer starve permitted ones); --probe applies the same policy filter as --bulk (skipped_policy_* fields on the report). - version-bump repair accepts a genuine literal 0.0.0.0 VERSION file. - slug heal restricted to the stray-.git shape — package.json-anchored wrapper roots keep their legit sticky identity (#2212 preserved). - brain-sync: idle fast path sees leftover .migrating records; unparseable spool records quarantine instead of warning forever; migration comment stops overclaiming the transition-window race. - CDP throttling justifications document override persistence (callers own restoration), pinned in the allowlist test. Deferred with record: deny retroactivity for already-ingested pages (P2 TODO, same semantics as the code-import gate); legacy-migration tail race (transition-window, requires pre-spool writers). 288 pass / 0 fail across the 10 touched suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: regenerate SKILL.md docs + goldens (Windows-separator jq fix) Pure generator output for the brain-sync block's jq ancestor match now accepting backslash-formed Windows project keys — previously project-scoped brains were invisible on Windows while the TS scope resolvers saw them. Golden ship fixtures refreshed per the documented procedure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: codex verify-pass residuals — chunked cwd read, post-filter partial count, migrating depth The verify re-review passed the P1 gate (0 P1s) and left three residuals, all applied: transcriptCwdFromPrefix reads in chunks until one complete record (4MB cap) so a giant first prompt can't truncate mid-JSON and break probe/bulk parity; partial_pages derives from the FINAL prepared set instead of the whole scanned corpus; the preamble queue-depth line counts leftover .brain-queue.jsonl.migrating records like the status path does (regen + goldens included). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.68.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.68.0.0 BROWSER.md: fix the $B cdp example (positional JSON params, not --json; depth is the real CDP param) and add the new perf-throttling examples (Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions) with their clear-override counterparts. USING_GBRAIN_WITH_GSTACK.md: the state-files table row for the sync queue now names the maildir-style spool dir .brain-queue.d/ that replaced .brain-queue.jsonl this release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align memory-pipeline probe pins with the #2394 stage-count contract The paid-tier E2E pinned the pre-fix contract (probe headline = raw discovered). Probe now counts post-attribution — the same gate --bulk uses — with an explicit unattributed-skip line. Adds the --include-unattributed companion pin so all 9 fixtures stay accounted for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(next-version): batch missing-tip fetches — one bounded round trip, never a per-branch crawl The targeted-fetch retry for branches whose advertised tip has no local object ran ONE git fetch per branch (10s cap each). On a shallow clone against a busy remote that crawls the network for minutes — CI's shard deadline killed the free suite mid-file. Missing tips now collect into a single batched shallow fetch (15s cap); refs still missing after the batch (one unservable ref fails the whole transfer) get a capped per-branch retry, and anything past the cap warns as an UNKNOWN claim instead of fetching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the batched fetch + make the offline-contract tests hermetic Two new G2 pins: N unfetched claim branches resolve with exactly ONE fetch spawn (PATH-shimmed git counts invocations), and one unservable ref no longer poisons the batch — live claims resolve via the bounded retry while only the ghost warns UNKNOWN. The #2545 offline-contract tests now run the CLI in a local fixture repo instead of the repo's own checkout: the checkout path did a live ls-remote against the real origin (operator-network-dependent, and the CI shard-deadline hang). The online-contract test gains a succeeding gh stub, so fallback:null is asserted deterministically instead of only when the operator happens to be authed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(redact-cli): derive the synthetic AWS-key fixture — no contiguous credential literal in source The CI quality gate scans every ADDED diff line with the redact engine, so the #2610 port's raw fixture literals failed the very gate they exist to test. The fixture is now assembled at runtime; the scanner still receives the identical bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(next-version): pin the fixture's host via origin-URL sniff — kills the last environment dependence The hermetic offline-contract fixture had no origin remote, so detectHost() fell through to auth probes: a machine with glab authed passed via the gitlab path while a bare CI runner read host:unknown (offline stays false there) and failed. The fixture now pushes to a local bare origin at a path containing github.com — the URL sniff pins host:github identically everywhere, asserted explicitly in both tests, with every git call still local. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ricky <ricky@kinokostudio.com.hk> Co-authored-by: Connex Client Access <paul@paulkortman.com> Co-authored-by: henbima <henbima@gmail.com>
1469 lines
67 KiB
Markdown
1469 lines
67 KiB
Markdown
# 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 (L1–L6)](#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 (10000–49151, deliberately below the macOS ephemeral pool
|
||
49152-65535 so the OS never hands a colliding port to another process),
|
||
generates a bearer token, writes the state file (chmod 600), starts
|
||
accepting requests. ~3 seconds. One launch-time exception to fail-fast:
|
||
when a macOS XProtect definition update SIGKILLs the pinned Chromium at
|
||
spawn, the daemon classifies the kill signature, clears the quarantine
|
||
flag on the Playwright cache, reinstalls the pinned revision from the
|
||
gstack install root (bounded ~120s), and retries once — at most once per
|
||
daemon process. If the heal can't complete, the original launch error
|
||
plus manual `bunx playwright install chromium` guidance lands on daemon
|
||
stderr (see `browse-daemon.log`). Wired at all three launch sites in
|
||
`browser-manager.ts` via `browse/src/xprotect-heal.ts`.
|
||
2. **Subsequent calls.** CLI reads the state file, sends an HTTP POST with
|
||
the bearer token, prints the response. ~100-200ms round trip.
|
||
3. **Idle shutdown.** After 30 minutes of no commands, daemon shuts down and
|
||
cleans up the state file. Next call restarts it.
|
||
4. **Crash recovery.** If Chromium crashes, the daemon exits immediately —
|
||
no self-healing, don't hide failure. CLI detects the dead daemon on the
|
||
next call and starts a fresh one.
|
||
5. **Busy vs dead.** A daemon that stops answering HTTP while its process is
|
||
alive is busy, not dead. The CLI gives `/health` a bounded ~8s to recover,
|
||
then reports busy with a nonzero exit — it never kills an alive pid.
|
||
Only an explicit `--force-restart` replaces a live-but-unresponsive
|
||
daemon (tabs, cookies, and logins are lost). `browse stop` against a
|
||
daemon that already died is success: the desired end state holds, so it
|
||
cleans the stale state file instead of booting a daemon just to stop it.
|
||
|
||
### Multi-workspace isolation
|
||
|
||
Each project root (detected via `git rev-parse --show-toplevel`) gets its
|
||
own daemon, port, state file, cookies, and logs. No cross-workspace
|
||
collisions. State at `<project>/.gstack/browse.json`.
|
||
|
||
| Workspace | State file | Port |
|
||
|-----------|-----------|------|
|
||
| `/code/project-a` | `/code/project-a/.gstack/browse.json` | random (10000–49151) |
|
||
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–49151) |
|
||
|
||
---
|
||
|
||
## Command reference
|
||
|
||
~70 commands across read, write, and meta. Selectors accept CSS, `@e` refs
|
||
from `snapshot`, or `@c` refs from `snapshot -C`. Full table:
|
||
|
||
### Reading
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `text [sel]` | Clean page text (or scoped to a selector) |
|
||
| `html [sel]` | innerHTML, or full page HTML if no selector |
|
||
| `links` | All links as `text → href` |
|
||
| `forms` | Form fields as JSON |
|
||
| `accessibility` | Full ARIA tree |
|
||
| `media [--images\|--videos\|--audio] [sel]` | Media elements with URLs, dimensions, types |
|
||
| `data [--jsonld\|--og\|--meta\|--twitter]` | Structured data: JSON-LD, OG, Twitter Cards, meta tags |
|
||
|
||
### Inspection
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `js <expr> [--out <file>] [--raw]` | Run inline JavaScript expression in page context, return as string. With `--out <file>` the result is written to disk instead of returned (a `data:*;base64,...` result is decoded to raw bytes unless `--raw`). `--out` makes the invocation a WRITE (needs `write` scope, never allowed over the tunnel). |
|
||
| `eval <file> [--out <file>] [--raw]` | Run JS from a file (path under /tmp or cwd; same sandbox as `js`). `--out`/`--raw` behave as for `js`. |
|
||
| `css <sel> <prop>` | Computed CSS value |
|
||
| `attrs <sel\|@ref>` | Element attributes as JSON |
|
||
| `is <prop> <sel\|@ref>` | State check: visible, hidden, enabled, disabled, checked, editable, focused |
|
||
| `console [--clear\|--errors]` | Captured console messages |
|
||
| `network [--clear]` | Captured network requests |
|
||
| `dialog [--clear]` | Captured dialog messages |
|
||
| `cookies` | All cookies as JSON |
|
||
| `storage` / `storage set <key> <val>` | Read both localStorage + sessionStorage; set localStorage |
|
||
| `perf` | Page load timings |
|
||
| `inspect [sel] [--all] [--history]` | Deep CSS via CDP — full rule cascade, box model, computed styles |
|
||
| `ux-audit` | Page structure for behavioral analysis: site ID, nav, headings, text blocks, interactive elements |
|
||
| `cdp <Domain.method> [json-params]` | Raw CDP method dispatch (deny-default; allowlist in `cdp-allowlist.ts`) |
|
||
|
||
### Navigation
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `goto <url>` | Navigate to URL (`http://`, `https://`, `file://`) |
|
||
| `load-html <file>` | Load local HTML in memory (no `file://` URL; survives viewport scale changes) |
|
||
| `back`, `forward`, `reload` | Standard nav |
|
||
| `url` | Current page URL |
|
||
| `wait <sel\|--networkidle\|--load>` | Wait for element, network idle, or page load (15s timeout) |
|
||
|
||
### Interaction
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `click <sel\|@ref>` | Click element |
|
||
| `fill <sel> <val>` | Fill input |
|
||
| `select <sel> <val>` | Select dropdown option (value, label, or visible text) |
|
||
| `hover <sel>` | Hover element |
|
||
| `type <text>` | Type into focused element |
|
||
| `press <key>` | Playwright keyboard key (case-sensitive: Enter, Tab, ArrowUp, Shift+Enter, Control+A, ...) |
|
||
| `scroll [sel\|@ref]` | Scroll element into view, or jump to page bottom if no selector |
|
||
| `viewport [<WxH>] [--scale <n>]` | Set viewport size + optional `deviceScaleFactor` 1-3 (retina screenshots) |
|
||
| `upload <sel> <file> [...]` | Upload file(s) |
|
||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt; text is sent for prompts |
|
||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||
|
||
### Style + cleanup
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `style <sel> <prop> <val>` | Modify CSS property (with undo support) |
|
||
| `style --undo [N]` | Undo last N style changes |
|
||
| `cleanup [--ads\|--cookies\|--sticky\|--social\|--all]` | Remove page clutter |
|
||
| `prettyscreenshot [--scroll-to <sel\|text>] [--cleanup] [--hide <sel>...] [path]` | Clean screenshot with optional cleanup, scroll, hide |
|
||
|
||
### Visual
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `screenshot [--selector <css>] [--viewport] [--clip x,y,w,h] [--base64] [sel\|@ref] [path]` | Five modes: full page, viewport, element crop, region clip, base64 |
|
||
| `pdf [path] [--format letter\|a4\|legal] [...]` | PDF with full layout: format, width/height, margins, header/footer templates, page numbers, --tagged for accessibility, --toc waits for Paged.js |
|
||
| `responsive [prefix]` | Three screenshots: mobile (375x812), tablet (768x1024), desktop (1280x720) |
|
||
| `diff <url1> <url2>` | Text diff between two URLs |
|
||
|
||
### Cookies + headers
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||
| `cookie-import <json>` | Import cookies from JSON file |
|
||
| `cookie-import-browser [browser] [--domain d]` | Import from installed Chromium browsers (interactive picker, or `--domain` for direct import) |
|
||
| `header <name>:<value>` | Set custom request header (sensitive values auto-redacted) |
|
||
| `useragent <string>` | Set user agent (triggers context recreation, invalidates refs) |
|
||
|
||
### Tabs + frames
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `tabs` | List open tabs |
|
||
| `tab <id>` | Switch to tab |
|
||
| `newtab [url] [--json]` | Open new tab; `--json` returns `{tabId, url}` for programmatic use |
|
||
| `closetab [id]` | Close tab |
|
||
| `tab-each <command> [args...]` | Fan out a command across every open tab; returns JSON |
|
||
| `frame <sel\|@ref\|--name n\|--url pattern\|main>` | Switch to iframe context (or back to main); clears refs |
|
||
|
||
### Extraction
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `download <url\|@ref> [path] [--base64]` | Download URL or media element using browser cookies |
|
||
| `scrape <images\|videos\|media> [--selector] [--dir] [--limit]` | Bulk download all media from page; writes `manifest.json` |
|
||
| `archive [path]` | Save complete page as MHTML via CDP |
|
||
|
||
### Snapshot
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o path] [-C]` | Accessibility tree with `@e` refs; `-i` interactive only, `-c` compact, `-d N` depth, `-s` scope, `-D` diff vs previous, `-a` annotated screenshot, `-C` cursor-interactive `@c` refs |
|
||
|
||
### Server lifecycle
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `status` | Daemon health + mode (headless / headed / cdp) |
|
||
| `stop` | Shut down daemon (succeeds even if the daemon already died — never boots one just to stop it) |
|
||
| `restart` | Restart daemon |
|
||
| `connect` | Launch headed GStack Browser with Side Panel extension |
|
||
| `disconnect` | Close headed Chrome, return to headless |
|
||
| `focus [@ref]` | Bring headed Chrome to foreground (macOS); `@ref` also scrolls into view |
|
||
| `state save\|load <name>` | Save or load browser state (cookies + URLs) |
|
||
| `memory [--json]` | Snapshot Bun heap + per-tab JS heap + Chromium process tree + bounded buffer sizes. Use `--json` for programmatic consumers; text mode renders sorted top-10 tabs with "and N more" tail. |
|
||
|
||
The daemon's own stdout/stderr persists to `<project>/.gstack/browse-daemon.log`
|
||
(append mode, rotated to `.log.1` at the size cap, single generation), with
|
||
tokens and unsanitized page content kept out — check it when a daemon dies
|
||
without an obvious cause. A live-but-unresponsive daemon is never auto-killed;
|
||
pass `--force-restart` to replace it explicitly (see "Daemon lifecycle" above).
|
||
|
||
### Handoff
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `handoff [reason]` | Open visible Chrome at current page for user takeover (CAPTCHA, MFA, complex auth) |
|
||
| `resume` | Re-snapshot after user takeover, return control to AI |
|
||
|
||
### Meta + chains
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `chain` (JSON via stdin) | Run a sequence of commands. Pipe `[["cmd","arg1",...],...]` to `$B chain`. Stops at first error. |
|
||
| `inbox [--clear]` | List messages from sidebar scout inbox |
|
||
| `watch [stop]` | Passive observation — periodic snapshots while user browses; `stop` returns summary |
|
||
|
||
### Browser-skills runtime
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `skill list` | List all browser-skills with resolved tier (project > global > bundled) |
|
||
| `skill show <name>` | Print SKILL.md |
|
||
| `skill run <name> [--arg k=v...] [--timeout=Ns]` | Spawn the skill script with a per-spawn scoped token |
|
||
| `skill test <name>` | Run the skill's `script.test.ts` against bundled fixtures |
|
||
| `skill rm <name> [--global]` | Tombstone a user-tier skill |
|
||
|
||
### Domain-skills
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| `domain-skill save\|list\|show\|edit\|promote-to-global\|rollback\|rm <host?>` | Per-site agent notes (host derived from active tab). Lifecycle: quarantined → active (after N=3 successful uses without classifier flag) → global (explicit promote) |
|
||
|
||
Aliases: `setcontent`, `set-content`, `setContent` → `load-html` (canonicalized
|
||
before scope checks, so a read-scoped token can't use the alias to run a
|
||
write command).
|
||
|
||
---
|
||
|
||
## Snapshot system
|
||
|
||
The browser's key innovation is **ref-based element selection** built on
|
||
Playwright's accessibility tree API. No DOM mutation. No injected scripts.
|
||
Just Playwright's native AX API.
|
||
|
||
### How `@ref` works
|
||
|
||
1. `page.locator(scope).ariaSnapshot()` returns a YAML-like accessibility tree.
|
||
2. The snapshot parser assigns refs (`@e1`, `@e2`, ...) to each element.
|
||
3. For each ref, it builds a Playwright `Locator` (using `getByRole` + nth-child).
|
||
4. The ref→Locator map is stored on `BrowserManager`.
|
||
5. Later commands like `click @e3` look up the Locator and call `locator.click()`.
|
||
|
||
### Ref staleness detection
|
||
|
||
SPAs can mutate the DOM without navigation (React router, tab switches,
|
||
modals). When this happens, refs collected from a previous `snapshot` may
|
||
point to elements that no longer exist. `resolveRef()` runs an async
|
||
`count()` check before using any ref — if the element count is 0, it throws
|
||
immediately with a message telling the agent to re-run `snapshot`. Fails fast
|
||
(~5ms) instead of waiting for Playwright's 30-second action timeout.
|
||
|
||
### Extended snapshot features
|
||
|
||
- **`--diff` (`-D`).** Stores each snapshot as a baseline. On the next `-D`
|
||
call, returns a unified diff showing what changed. Use this to verify that
|
||
an action (click, fill, etc.) actually worked.
|
||
- **`--annotate` (`-a`).** Injects temporary overlay divs at each ref's
|
||
bounding box, takes a screenshot with ref labels visible, then removes the
|
||
overlays. Use `-o <path>` to control the output.
|
||
- **`--cursor-interactive` (`-C`).** Scans for non-ARIA interactive elements
|
||
(divs with `cursor:pointer`, `onclick`, `tabindex>=0`) using `page.evaluate`.
|
||
Assigns `@c1`, `@c2`... refs with deterministic `nth-child` CSS selectors.
|
||
These are elements the ARIA tree misses but users can still click.
|
||
|
||
---
|
||
|
||
## Browser-skills runtime
|
||
|
||
Per-task directories that codify a repeated browser flow into a deterministic
|
||
Playwright script. The compounding layer.
|
||
|
||
### Anatomy of a browser-skill
|
||
|
||
```
|
||
browser-skills/<name>/
|
||
├── SKILL.md # frontmatter + prose contract
|
||
├── script.ts # deterministic Playwright-via-browse-client logic
|
||
├── _lib/browse-client.ts # vendored copy of the SDK (~3KB, byte-identical to canonical)
|
||
├── fixtures/<host>-<date>.html # captured page for fixture-replay tests
|
||
└── script.test.ts # parser tests against the fixture (no daemon required)
|
||
```
|
||
|
||
The bundled reference is `browser-skills/hackernews-frontpage/`: scrapes the
|
||
HN front page, returns 30 stories as JSON. Try it:
|
||
|
||
```bash
|
||
$B skill list # shows hackernews-frontpage (bundled)
|
||
$B skill show hackernews-frontpage
|
||
$B skill run hackernews-frontpage # JSON of 30 stories in ~200ms
|
||
$B skill test hackernews-frontpage # runs script.test.ts against fixture
|
||
```
|
||
|
||
### Three-tier storage
|
||
|
||
`$B skill list` walks all three in priority order; first hit wins. Resolved
|
||
tier is printed inline next to each skill name:
|
||
|
||
| Tier | Path | When |
|
||
|------|------|------|
|
||
| **Project** | `<project>/.gstack/browser-skills/<name>/` | Project-specific skills (committed or gitignored) |
|
||
| **Global** | `~/.gstack/browser-skills/<name>/` | Per-user skills, all projects |
|
||
| **Bundled** | `<gstack-install>/browser-skills/<name>/` | Ships with gstack, read-only |
|
||
|
||
### Trust model
|
||
|
||
Two orthogonal axes — daemon-side capability and process-side env — independently
|
||
configured.
|
||
|
||
| Axis | Mechanism | Default |
|
||
|------|-----------|---------|
|
||
| **Daemon-side capability** | Per-spawn scoped token bound to read+write scope (browser-driving commands minus admin: `eval`, `js`, `cookies`, `storage`). Single-use clientId encodes skill name + spawn id. Revoked when spawn exits. | Always scoped — never the daemon root token |
|
||
| **Process-side env** | `trusted: true` frontmatter passes `process.env` minus `GSTACK_TOKEN`. `trusted: false` (default) drops everything except a minimal allowlist (LANG, LC_ALL, TERM, TZ) and pattern-strips secrets (TOKEN/KEY/SECRET/PASSWORD, AWS_*, ANTHROPIC_*, OPENAI_*, GITHUB_*, etc.) | Untrusted (must opt in) |
|
||
|
||
`GSTACK_PORT` and `GSTACK_SKILL_TOKEN` are injected last, so a parent process
|
||
can't override them.
|
||
|
||
### Output protocol
|
||
|
||
stdout = JSON. stderr = streaming logs. Exit 0 / non-zero. Default 60s
|
||
timeout, override via `--timeout=Ns`. Max stdout 1MB (truncate + non-zero
|
||
exit if exceeded). Matches `gh` / `kubectl` / `docker` conventions.
|
||
|
||
### How the SDK distribution works
|
||
|
||
Each skill ships its own copy of `browse-client.ts` at `_lib/browse-client.ts`,
|
||
byte-identical to the canonical `browse/src/browse-client.ts`. `/skillify`
|
||
copies the canonical SDK alongside every generated script. Each skill is
|
||
fully self-contained: copy the directory anywhere, it runs. Version drift
|
||
impossible — the SDK is frozen at the version the skill was authored against.
|
||
|
||
### Atomic write discipline (`/skillify` D3)
|
||
|
||
`browse/src/browser-skill-write.ts` provides three primitives:
|
||
|
||
- `stageSkill(opts)` — writes files to `~/.gstack/.tmp/skillify-<spawnId>/<name>/`
|
||
with restrictive perms.
|
||
- `commitSkill(opts)` — atomic `fs.renameSync` into the final tier path.
|
||
Refuses to follow symlinked staging dirs (`lstat` check), refuses to
|
||
clobber existing skills, runs `realpath` discipline on the tier root.
|
||
- `discardStaged(stagedDir)` — `rm -rf` the staged dir + per-spawn wrapper.
|
||
Idempotent. Called on test failure or approval rejection.
|
||
|
||
There is no "almost shipped" state. Tests pass + user approves = atomic
|
||
rename. Tests fail or user rejects = staging vanishes.
|
||
|
||
See [`docs/designs/BROWSER_SKILLS_V1.md`](docs/designs/BROWSER_SKILLS_V1.md)
|
||
for the full design rationale.
|
||
|
||
---
|
||
|
||
## Domain-skills
|
||
|
||
Different mental model from browser-skills: agent-authored *notes* about a
|
||
site (not deterministic scripts). One per hostname. Lifecycle:
|
||
|
||
1. `domain-skill save <host>` — agent writes a note about the site (e.g.,
|
||
"GitHub: PR creation needs `--draft` flag for non-staff", "X.com: timeline
|
||
uses cursor pagination, not page numbers"). Default state: **quarantined**.
|
||
2. After **N=3** successful uses without the L4 prompt-injection classifier
|
||
flagging the note, it auto-promotes to **active**.
|
||
3. `domain-skill promote-to-global <host>` lifts it to the global tier
|
||
(machine-wide, all projects).
|
||
4. `domain-skill rollback <host>` demotes; `domain-skill rm <host>` tombstones.
|
||
|
||
The classifier flag is set automatically by the L4 prompt-injection scan;
|
||
agents do not set it manually.
|
||
|
||
Storage:
|
||
- Per-project: `<project>/.gstack/domain-skills/<host>.md`
|
||
- Global: `~/.gstack/domain-skills/<host>.md`
|
||
|
||
Source: `browse/src/domain-skills.ts`, `domain-skill-commands.ts`.
|
||
|
||
---
|
||
|
||
## Real-browser mode
|
||
|
||
`$B connect` launches **GStack Browser** — a rebranded Chromium controlled by
|
||
Playwright with the Side Panel extension auto-loaded and anti-bot stealth
|
||
patches applied. You watch every command tick through a visible window in
|
||
real time.
|
||
|
||
```bash
|
||
$B connect # launches GStack Browser, headed
|
||
$B goto https://app.com # navigates in the visible window
|
||
$B snapshot -i # refs from the real page
|
||
$B click @e3 # clicks in the real window
|
||
$B focus # bring window to foreground (macOS)
|
||
$B status # shows Mode: cdp
|
||
$B disconnect # back to headless mode
|
||
```
|
||
|
||
The window has a subtle golden shimmer line at the top and a floating
|
||
"gstack" pill in the bottom-right corner so you always know which Chrome
|
||
window is being controlled.
|
||
|
||
### What "GStack Browser" means
|
||
|
||
Not your daily Chrome — a Playwright-managed Chromium with custom branding
|
||
in the Dock and menu bar (the `.app` name, Dock icon, and tray, NOT the UA
|
||
string), always-on Layer C anti-bot stealth (most JS-observable automation
|
||
tells are masked, so many anti-bot-protected sites load cleanly), a
|
||
stock-Chrome user agent that reports the underlying Chromium version, and the
|
||
gstack extension pre-loaded via `launchPersistentContext`. The UA no longer
|
||
carries a `GStackBrowser` suffix — that branding string was itself a
|
||
high-entropy tell, so the browser now reports a plain `Chrome/<version>` UA.
|
||
Deepest-layer CDP-protocol detection still gets through (Google can still
|
||
trigger captchas; see the CDP-patch item in `TODOS.md`). Your regular Chrome
|
||
with your tabs and bookmarks stays untouched.
|
||
|
||
### When to use headed mode
|
||
|
||
- **QA testing** where you want to watch Claude click through your app
|
||
- **Design review** where you need to see exactly what Claude sees
|
||
- **Debugging** where headless behavior differs from real Chrome
|
||
- **Demos** where you're sharing your screen
|
||
- **Pair-agent** sessions (the remote agent drives your local browser)
|
||
|
||
### CDP-aware skills
|
||
|
||
When in real-browser mode, `/qa` and `/design-review` automatically skip
|
||
cookie import prompts and headless workarounds — the headed browser already
|
||
has whatever session you logged into.
|
||
|
||
### Headed mode + proxy + browser-native downloads (v1.28.0.0)
|
||
|
||
Three coordinated flags for sites that block headless browsers, fingerprint
|
||
Playwright defaults, or sit behind authenticated upstream proxies:
|
||
|
||
```bash
|
||
# Visible Chromium. Auto-spawns Xvfb on Linux containers without DISPLAY.
|
||
$B --headed goto https://example.com
|
||
|
||
# SOCKS5 with auth — Chromium can't prompt for SOCKS5 creds, so $B runs a
|
||
# local 127.0.0.1 bridge that handles the auth handshake.
|
||
$B --proxy socks5://user:pass@residential.proxy.host:1080 goto https://example.com
|
||
|
||
# HTTP/HTTPS proxy passes through to Chromium directly.
|
||
$B --proxy http://corp-proxy:3128 goto https://example.com
|
||
|
||
# Browser-native download for Content-Disposition, redirect chains, anti-bot
|
||
# CDNs where page.request.fetch() falls over.
|
||
$B download "https://protected.example.com/file" /tmp/file.bin --navigate
|
||
|
||
# Combined.
|
||
$B --headed --proxy socks5://user:pass@host:1080 \
|
||
download "https://protected.example.com/file" /tmp/file.bin --navigate
|
||
```
|
||
|
||
**Credential policy.** Pass creds via the URL (`socks5://user:pass@host`) OR
|
||
the env vars `BROWSE_PROXY_USER` / `BROWSE_PROXY_PASS` — never both. `$B`
|
||
refuses with a clear hint when both are set; silent override created
|
||
"works on my machine" debugging traps.
|
||
|
||
**Daemon discipline.** `--proxy` and `--headed` are daemon-startup config.
|
||
A running daemon with config A meeting a new invocation with config B exits
|
||
1 with a `browse disconnect` hint instead of silently restarting and dropping
|
||
tab state, cookies, or sessions.
|
||
|
||
**Stealth scope (Layer C, always on).** Every context — headless `launch`,
|
||
`--headed`/`--proxy`, `handoff`, and the `useragent`/`viewport --scale`
|
||
rebuild (`recreateContext`) — gets the full Layer C mask, no opt-in flag.
|
||
Layer C masks `navigator.webdriver`, restores the `window.chrome.*` shape
|
||
(`runtime`, `app`, `csi`, `loadTimes`), aligns `Notification.permission`
|
||
with the Permissions API, reports a per-install
|
||
`hardwareConcurrency`/`deviceMemory` from the host profile, sweeps the known
|
||
Selenium/Phantom/Nightmare/Playwright globals, and installs a
|
||
`Function.prototype.toString` proxy so every patched getter reports
|
||
`[native code]` even under the depth-3 recursion check. It still does NOT
|
||
fake `navigator.plugins` or `navigator.languages` — modern fingerprinters
|
||
cross-check those for consistency, and synthesizing fixed values flags MORE
|
||
bot-like, not less. ChromeDriver's `cdc_`/`__webdriver` runtime artifacts and
|
||
the Permissions notifications tell are also cleaned up on every path.
|
||
|
||
`GSTACK_STEALTH=extended` (also accepts `1` or `true`; off by default) layers
|
||
six more aggressive patches on top — WebGL renderer spoof, a faked
|
||
`navigator.plugins` PluginArray, `navigator.mediaDevices`. That mode actively
|
||
lies and can break sites that reflect on those properties; use it only when
|
||
the default triggers detection. For gbrowser builds with the C++ patches, the
|
||
`GSTACK_*` host-profile env (GPU vendor/renderer, UA-CH platform/model,
|
||
hardware) emits the Pack 1 `--gstack-gpu-vendor` / `--gstack-gpu-renderer` /
|
||
`--gstack-ua-platform` / `--gstack-ua-model` / `--gstack-hw-concurrency` /
|
||
`--gstack-device-memory` switches that push the GPU/UA-CH/hardware spoof down
|
||
to native code, and `GSTACK_CDP_STEALTH=on` (or `1`/`true`) emits the Pack 2
|
||
`--gstack-suppress-prepare-stack-trace` switch (closes the Cloudflare
|
||
`Error.prepareStackTrace` canary). On stock Playwright Chromium every one of
|
||
these switches is a safe no-op.
|
||
|
||
`launchHeaded` / `handoff` also strip Playwright's automation-tell launch
|
||
defaults via `ignoreDefaultArgs` (`STEALTH_IGNORE_DEFAULT_ARGS`):
|
||
`--enable-automation` (the "Chrome is being controlled by automated test
|
||
software" infobar), `--disable-extensions`,
|
||
`--disable-component-extensions-with-background-pages`,
|
||
`--disable-popup-blocking`, `--disable-component-update`, and
|
||
`--disable-default-apps`.
|
||
|
||
**Container support.** `--headed` on Linux without `DISPLAY` walks the
|
||
display range (`:99`, `:100`, ...) until `xdpyinfo` reports a free slot,
|
||
then spawns Xvfb. Cleanup-on-disconnect validates the recorded PID's
|
||
`/proc/<pid>/cmdline` matches `Xvfb` AND start-time matches before sending
|
||
any signal — no PID-reuse footguns. Skips spawn entirely when
|
||
`WAYLAND_DISPLAY` is set (Chromium uses Wayland natively). Standard
|
||
Debian/Ubuntu containers work out of the box; minimal images (alpine,
|
||
distroless) may need fonts/dbus/gtk libs for headed Chromium to render.
|
||
|
||
**Failure modes.** SOCKS5 upstream rejected or unreachable — fail-fast at
|
||
startup with a redacted error after 3 retries (5s budget). Mid-stream
|
||
upstream drop — bridge kills the affected client connection only; no
|
||
transport retries that could corrupt browser traffic.
|
||
|
||
---
|
||
|
||
## Side Panel + sidebar agent
|
||
|
||
The Chrome extension that ships baked into GStack Browser shows a live
|
||
activity feed of every browse command in a Side Panel, plus `@ref` overlays
|
||
on the page, plus an interactive Claude PTY inside the sidebar.
|
||
|
||
### The Terminal pane (the headline)
|
||
|
||
The Side Panel's primary surface is the **Terminal pane** — a live `claude -p`
|
||
PTY you can type into directly from the sidebar. Activity / Refs / Inspector
|
||
are debug overlays behind the footer's `debug` toggle. WebSocket auth uses
|
||
`Sec-WebSocket-Protocol` (browsers can't set `Authorization` on a WebSocket
|
||
upgrade), and the PTY session token is a 30-minute HttpOnly cookie minted
|
||
via `POST /pty-session`.
|
||
|
||
The toolbar's Cleanup button and the Inspector's "Send to Code" action both
|
||
pipe text into the live Claude PTY via `window.gstackInjectToTerminal(text)`,
|
||
exposed by `sidepanel-terminal.js`. There's no separate `/sidebar-command`
|
||
POST — the live REPL is the only execution surface.
|
||
|
||
### Activity feed
|
||
|
||
A scrolling feed of every browse command — name, args, duration, status,
|
||
errors. Shows up in real time as Claude works. Backed by SSE (`/activity/stream`)
|
||
that accepts the Bearer token OR the HttpOnly `gstack_sse` session cookie
|
||
(30-minute stream-scope cookie minted via `POST /sse-session`).
|
||
|
||
### Refs tab
|
||
|
||
After `$B snapshot`, shows the current `@ref` list (role + name) so you can
|
||
see what Claude is targeting.
|
||
|
||
### CSS Inspector
|
||
|
||
Powered by `$B inspect` (CDP-based). Click any element on the page to see the
|
||
full CSS rule cascade, computed styles, box model, and modification history.
|
||
The "Send to Code" button injects a description into the Claude PTY.
|
||
|
||
### Sidebar architecture
|
||
|
||
| Component | Where it lives | Notes |
|
||
|-----------|----------------|-------|
|
||
| Side Panel UI | `extension/sidepanel.js`, `sidepanel-terminal.js` | Chrome extension surface |
|
||
| Background SW | `extension/background.js` | Manages tab events, port management |
|
||
| Content script | `extension/content.js` | Page overlays, `gstack` pill |
|
||
| Terminal agent | `browse/src/terminal-agent.ts` | PTY spawn, lifecycle, auth |
|
||
| Sidebar utilities | `browse/src/sidebar-utils.ts` | URL sanitization, helpers |
|
||
|
||
Before modifying any of these, read the comment block in `CLAUDE.md` under
|
||
"Sidebar architecture" — silent failures here usually trace to not understanding
|
||
the cross-component flow.
|
||
|
||
### Manual install (for your regular Chrome)
|
||
|
||
If you want the extension in your everyday Chrome (not the Playwright-controlled
|
||
one):
|
||
|
||
```bash
|
||
bin/gstack-extension # opens chrome://extensions, copies path to clipboard
|
||
```
|
||
|
||
Or do it manually: `chrome://extensions` → toggle Developer mode → Load
|
||
unpacked → navigate to `~/.claude/skills/gstack/extension` → pin the
|
||
extension → enter the port from `$B status`.
|
||
|
||
v1.63 pinned the extension identity via the manifest `key` field, so existing
|
||
unpacked installs get a new extension ID and panel-local state (saved port)
|
||
resets once — a one-time in-product notice explains this.
|
||
|
||
---
|
||
|
||
## Pair-agent
|
||
|
||
Remote AI agents (Codex, OpenClaw, Hermes, anything that speaks HTTP) can
|
||
drive your local browser through an ngrok tunnel. The whole flow is gated
|
||
by a 26-command allowlist, scoped tokens, and a denial log.
|
||
|
||
### How it works
|
||
|
||
```bash
|
||
/pair-agent # generates a setup key, prints connection instructions
|
||
# Copy the instructions to the remote agent
|
||
# Remote agent runs:
|
||
# POST <tunnel-url>/connect with setup key → gets a scoped token (24h, single client)
|
||
# POST <tunnel-url>/command with token → runs allowed commands
|
||
```
|
||
|
||
### Dual-listener architecture (v1.6.0.0+)
|
||
|
||
When `pair-agent` activates, the daemon binds **two HTTP listeners**:
|
||
|
||
- **Local listener** (`127.0.0.1:LOCAL_PORT`). Full command surface. Never
|
||
forwarded by ngrok. Used by your Claude Code, the Side Panel, anything
|
||
on your machine.
|
||
- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`). Locked allowlist —
|
||
`/connect`, `/command` (scoped tokens + 26-command browser-driving
|
||
allowlist), `/sidebar-chat`. ngrok forwards only this port.
|
||
|
||
Root tokens sent over the tunnel return 403. SSE endpoints use a 30-minute
|
||
HttpOnly `gstack_sse` cookie (never valid against `/command`).
|
||
|
||
### The 26-command tunnel allowlist
|
||
|
||
Defined in `browse/src/server.ts` as `TUNNEL_COMMANDS`. Pure gate function
|
||
`canDispatchOverTunnel(command)` is exported for unit testing. Set:
|
||
|
||
```
|
||
goto, click, text, screenshot, html, links, forms, accessibility,
|
||
attrs, media, data, scroll, press, type, select, wait, eval,
|
||
newtab, tabs, back, forward, reload, snapshot, fill, url, closetab
|
||
```
|
||
|
||
Notably absent: `pair`, `unpair`, `cookies`, `setup`, `launch`, `restart`,
|
||
`stop`, `tunnel-start`, `token-mint`, `state`, `connect`, `disconnect`. A
|
||
remote agent that tries them gets a 403 plus a fresh entry in the denial log.
|
||
|
||
### Tunnel denial log
|
||
|
||
`~/.gstack/security/attempts.jsonl` — append-only, salted SHA-256 of source
|
||
+ domain only (no raw IP, no full request body), rotates at 10MB with 5
|
||
generations. Per-device salt at `~/.gstack/security/device-salt` (mode 0600).
|
||
|
||
### Tunnel egress receipts (v1.63+)
|
||
|
||
Every tunnel session open writes a hash-chained egress receipt (sink
|
||
`browse-tunnel`) to `~/.gstack/security/egress.jsonl` BEFORE ngrok forwards
|
||
anything. Fail-closed: if the receipt can't be written, the tunnel listener
|
||
is torn down and the start is refused. Inspect the ledger with
|
||
`bin/gstack-egress list` and verify chain integrity with
|
||
`bin/gstack-egress verify` (exit 3 on tamper).
|
||
|
||
See [`docs/REMOTE_BROWSER_ACCESS.md`](docs/REMOTE_BROWSER_ACCESS.md) for the
|
||
full operator guide.
|
||
|
||
### Tab ownership
|
||
|
||
Scoped tokens default to `tabPolicy: 'own-only'`. A paired agent can `newtab`
|
||
to create its own tab and drive that tab freely, but it can't `goto`, `fill`,
|
||
or `click` on tabs another caller owns. `tabs` lists ALL tab metadata (an
|
||
accepted tradeoff — see ARCHITECTURE.md), but `text`/`html`/`snapshot` content
|
||
of unowned tabs is blocked by ownership checks.
|
||
|
||
---
|
||
|
||
## Authentication
|
||
|
||
Three token types, three lifetimes, three scopes.
|
||
|
||
| Token | Generated by | Lifetime | Scope |
|
||
|-------|--------------|----------|-------|
|
||
| **Root token** | Daemon startup (random UUID) | Daemon process lifetime | Full command surface, local listener only — 403 over tunnel |
|
||
| **Setup key** | `POST /pair` | 5 minutes, one-time use | Single redemption: present at `/connect`, get a scoped token |
|
||
| **Scoped token** | `POST /connect` (with setup key) | 24 hours | Per-client, allowlist-bound, optionally tab-scoped |
|
||
|
||
The root token is written to `<project>/.gstack/browse.json` with chmod 600.
|
||
Every command that mutates browser state must include
|
||
`Authorization: Bearer <token>`.
|
||
|
||
### SSE session cookie (v1.6.0.0+)
|
||
|
||
SSE endpoints (`/activity/stream`, `/inspector/events`) accept the Bearer
|
||
token OR a 30-minute HttpOnly `gstack_sse` cookie minted via
|
||
`POST /sse-session`. The `?token=<ROOT>` query-param auth is no longer
|
||
supported. This is what lets the Chrome extension subscribe to the activity
|
||
feed without putting the root token in extension storage.
|
||
|
||
### PTY session cookie
|
||
|
||
The Terminal pane uses a separate session cookie, `gstack_pty`, minted via
|
||
`POST /pty-session`. Different scope — can spawn / drive the live `claude`
|
||
PTY, can't dispatch arbitrary `/command` calls. `/health` endpoint MUST NOT
|
||
surface this token.
|
||
|
||
### Extension token bootstrap (v1.63+)
|
||
|
||
`GET /health` is liveness/status only — it never carries a token, in any
|
||
mode. The Side Panel extension bootstraps the root token via
|
||
`POST /extension-token` on the local listener. The server releases the
|
||
token only when the caller's Origin is exactly
|
||
`chrome-extension://<GSTACK_EXTENSION_ID>` — the `key` field in
|
||
`extension/manifest.json` pins the extension ID (`GSTACK_EXTENSION_ID` in
|
||
`browse/src/server.ts`; derivation reproducible via
|
||
`bun browse/scripts/extension-id.ts`) — AND the parsed Host hostname is
|
||
loopback. Anything else gets a detail-free 403. The endpoint is never
|
||
added to `TUNNEL_PATHS`, so the tunnel surface 404s it by default-deny.
|
||
|
||
### Token registry
|
||
|
||
`browse/src/token-registry.ts` handles mint/validate/revoke for all three
|
||
types, plus per-token rate limiting. Setup keys are single-use; scoped
|
||
tokens have a sliding 24h window; the root token is rotated on each daemon
|
||
startup.
|
||
|
||
---
|
||
|
||
## Security stack
|
||
|
||
Layered defense against prompt injection on untrusted page content.
|
||
|
||
| Layer | Module | Lives in |
|
||
|-------|--------|----------|
|
||
| **L1** Datamarking | `content-security.ts` | server + page-content read path |
|
||
| **L2** Hidden-element strip | `content-security.ts` | server + page-content read path |
|
||
| **L3** ARIA + URL blocklist + envelope wrapping | `content-security.ts` | server + page-content read path |
|
||
| **L4** TestSavantAI ML classifier (112MB ONNX) | `security-classifier.ts` | security sidecar subprocess* |
|
||
| Canary token utilities | `security.ts` | pure functions — no live injector today |
|
||
| `combineVerdict` ensemble | `security.ts` | server (inline L4 verdict path) |
|
||
|
||
\* `security-classifier.ts` cannot be imported from the compiled browse
|
||
binary — `@huggingface/transformers` v4 requires `onnxruntime-node` which
|
||
fails to `dlopen` from Bun compile's temp extract dir. The compiled binary
|
||
runs L1–L3 plus the pure parts of `security.ts`; L4 runs in a plain-Node
|
||
sidecar (`security-sidecar-entry.ts`, spawned lazily by
|
||
`security-sidecar-client.ts` on the first `/pty-inject-scan`).
|
||
|
||
### Thresholds
|
||
|
||
- `BLOCK: 0.85` — single-layer score that would cause BLOCK if cross-confirmed
|
||
- `WARN: 0.75` — cross-confirm threshold in `combineVerdict`
|
||
- `LOG_ONLY: 0.40` — log-only floor
|
||
- `SOLO_CONTENT_BLOCK: 0.92` — single-layer threshold for label-less content classifiers
|
||
|
||
### Ensemble rule
|
||
|
||
`combineVerdict` retains multi-layer ensemble semantics (2-of-N block votes;
|
||
single-layer high confidence degrades to WARN — the Stack Overflow
|
||
instruction-writing FP mitigation), but only L4 (testsavant) is live today:
|
||
the Haiku transcript and DeBERTa ensemble layers were removed along with the
|
||
sidebar chat pipeline that hosted them. **Canary leak always BLOCKs
|
||
(deterministic).**
|
||
|
||
### Env knobs
|
||
|
||
- `GSTACK_SECURITY_OFF=1` — emergency kill switch. Classifier stays off
|
||
even if warmed. Just the ML scan is skipped.
|
||
- Classifier model cache: `~/.gstack/models/testsavant-small/` (112MB, first
|
||
run only).
|
||
- Attack log: `~/.gstack/security/attempts.jsonl` (salted SHA-256 + domain
|
||
only, rotates at 10MB, 5 generations).
|
||
- Per-device salt: `~/.gstack/security/device-salt` (0600).
|
||
|
||
There is no security status indicator in the sidebar and no `security`
|
||
field on `/health` (#2557): the session-state file that fed them lost its
|
||
only writer when the chat-path agent was removed, so they reported stale or
|
||
empty data. The live defenses report through their own call sites. See
|
||
ARCHITECTURE.md § "Prompt injection defense" for the full threat model.
|
||
|
||
---
|
||
|
||
## Screenshots, PDFs, visual
|
||
|
||
### Screenshot modes
|
||
|
||
| Mode | Syntax | Playwright API |
|
||
|------|--------|----------------|
|
||
| Full page (default) | `screenshot [path]` | `page.screenshot({ fullPage: true })` |
|
||
| Viewport only | `screenshot --viewport [path]` | `page.screenshot({ fullPage: false })` |
|
||
| Element crop (flag) | `screenshot --selector <css> [path]` | `locator.screenshot()` |
|
||
| Element crop (positional) | `screenshot "#sel" [path]` or `screenshot @e3 [path]` | `locator.screenshot()` |
|
||
| Region clip | `screenshot --clip x,y,w,h [path]` | `page.screenshot({ clip })` |
|
||
|
||
Element crop accepts CSS selectors (`.class`, `#id`, `[attr]`) or `@e`/`@c`
|
||
refs. **Tag selectors like `button` aren't caught by the positional
|
||
heuristic** — use the `--selector` flag form.
|
||
|
||
`--base64` returns `data:image/png;base64,...` instead of writing to disk —
|
||
composes with `--selector`, `--clip`, `--viewport`.
|
||
|
||
Mutual exclusion: `--clip` + selector, `--viewport` + `--clip`, and
|
||
`--selector` + positional selector all throw.
|
||
|
||
### Retina screenshots — `viewport --scale`
|
||
|
||
`viewport --scale <n>` sets Playwright's `deviceScaleFactor` (context-level,
|
||
1–3 cap):
|
||
|
||
```bash
|
||
$B viewport 480x600 --scale 2
|
||
$B load-html /tmp/card.html
|
||
$B screenshot /tmp/card.png --selector .card
|
||
# .card at 400x200 CSS pixels → card.png is 800x400 pixels
|
||
```
|
||
|
||
`--scale N` alone (no `WxH`) keeps the current viewport size. Scale changes
|
||
trigger a context recreation, which invalidates `@e`/`@c` refs — rerun
|
||
`snapshot` after. HTML loaded via `load-html` survives the recreation via
|
||
in-memory replay. Rejected in headed mode (real browser controls scale).
|
||
|
||
### PDF generation
|
||
|
||
`pdf` accepts the full Playwright surface plus a few additions:
|
||
|
||
- **Layout:** `--format letter|a4|legal`, `--width <dim>`, `--height <dim>`,
|
||
`--margins <dim>`, `--margin-top/right/bottom/left <dim>`
|
||
- **Structure:** `--toc` (waits for Paged.js if loaded), `--outline`,
|
||
`--tagged` (PDF/A accessibility), `--print-background`,
|
||
`--prefer-css-page-size`
|
||
- **Branding:** `--header-template <html>`, `--footer-template <html>`,
|
||
`--page-numbers`
|
||
- **Tabs:** `--tab-id <N>` to render a specific tab
|
||
- **Large payloads:** `--from-file <payload.json>` (avoids shell argv limits)
|
||
|
||
### Responsive screenshots
|
||
|
||
`responsive [prefix]` — three screenshots in one call: mobile (375x812),
|
||
tablet (768x1024), desktop (1280x720). Saves as `{prefix}-mobile.png` etc.
|
||
|
||
### `prettyscreenshot`
|
||
|
||
Combines cleanup + scroll + element hide in one call:
|
||
|
||
```bash
|
||
$B prettyscreenshot --cleanup --scroll-to "hero section" --hide ".cookie-banner" /tmp/clean.png
|
||
```
|
||
|
||
---
|
||
|
||
## Local HTML
|
||
|
||
Two ways to render HTML that isn't on a web server:
|
||
|
||
| Approach | When | URL after | Relative assets |
|
||
|----------|------|-----------|-----------------|
|
||
| `goto file://<abs-path>` | File already on disk | `file:///...` | Resolve against file's directory |
|
||
| `goto file://./<rel>`, `goto file://~/<rel>` | Smart-parsed to absolute | `file:///...` | Same |
|
||
| `load-html <file>` | HTML generated in memory, no parent-dir context needed | `about:blank` | Broken (self-contained HTML only) |
|
||
|
||
Both are scoped to files under cwd or `$TMPDIR` via the same safe-dirs
|
||
policy as `eval`. `file://` URLs preserve query strings and fragments (SPA
|
||
routes work).
|
||
|
||
`load-html` has an extension allowlist (`.html`, `.htm`, `.xhtml`, `.svg`) and
|
||
a magic-byte sniff to reject binary files mis-renamed as HTML. 50MB size cap
|
||
(override via `GSTACK_BROWSE_MAX_HTML_BYTES`).
|
||
|
||
`load-html` content survives later `viewport --scale` calls via in-memory
|
||
replay (TabSession tracks the loaded HTML + waitUntil). The replay is
|
||
purely in-memory — HTML is never persisted to disk via `state save` to
|
||
avoid leaking secrets or customer data.
|
||
|
||
---
|
||
|
||
## Batch endpoint
|
||
|
||
`POST /batch` sends multiple commands in a single HTTP request. Eliminates
|
||
per-command round-trip latency — critical for remote agents over ngrok where
|
||
each HTTP call costs 2-5s.
|
||
|
||
```json
|
||
POST /batch
|
||
Authorization: Bearer <token>
|
||
|
||
{
|
||
"commands": [
|
||
{"command": "text", "tabId": 1},
|
||
{"command": "text", "tabId": 2},
|
||
{"command": "snapshot", "args": ["-i"], "tabId": 3},
|
||
{"command": "click", "args": ["@e5"], "tabId": 4}
|
||
]
|
||
}
|
||
```
|
||
|
||
Each command routes through `handleCommandInternal` — full security pipeline
|
||
(scope checks, domain validation, tab ownership, content wrapping) enforced
|
||
per command. Per-command error isolation: one failure doesn't abort the
|
||
batch. Max 50 commands per batch. Nested batches rejected. Rate limiting:
|
||
1 batch = 1 request against the per-agent limit.
|
||
|
||
Pattern: agent crawling 20 pages opens 20 tabs (individual `newtab` or
|
||
batch), then `POST /batch` with 20 `text` commands → 20 page contents in
|
||
~2-3 seconds total vs ~40-100 seconds serial.
|
||
|
||
---
|
||
|
||
## Capture
|
||
|
||
Console, network, and dialog events flow into O(1) circular buffers (50,000
|
||
capacity each), flushed to disk asynchronously via `Bun.write()`:
|
||
|
||
- Console: `.gstack/browse-console.log`
|
||
- Network: `.gstack/browse-network.log`
|
||
- Dialog: `.gstack/browse-dialog.log`
|
||
|
||
The `console`, `network`, and `dialog` commands read from the in-memory
|
||
buffers (not disk) so capture is real-time even when disk is slow.
|
||
|
||
Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent
|
||
browser lockup. `dialog-accept <text>` controls prompt response text.
|
||
|
||
---
|
||
|
||
## JS execution
|
||
|
||
`js` runs an inline expression. `eval` runs a JS file. Both run in the
|
||
**same JS sandbox** — the only difference is inline-vs-file. Both support
|
||
`await` — expressions containing `await` are auto-wrapped in an async
|
||
context:
|
||
|
||
```bash
|
||
$B js "await fetch('/api/data').then(r => r.json())" # auto-wrapped
|
||
$B js "document.title" # no wrap needed
|
||
$B eval my-script.js # file with await
|
||
```
|
||
|
||
For `eval` files, single-line files return the expression value directly.
|
||
Multi-line files need explicit `return` when using `await`. Comments
|
||
containing the literal token "await" don't trigger wrapping.
|
||
|
||
Path safety: `eval` rejects paths outside cwd or `/tmp`. `js` doesn't read
|
||
files at all.
|
||
|
||
---
|
||
|
||
## Tabs, frames, state
|
||
|
||
### Tabs
|
||
|
||
```bash
|
||
$B tabs # list all open tabs
|
||
$B tab 3 # switch to tab 3
|
||
$B newtab https://example.com # open new tab, switch to it
|
||
$B newtab --json # programmatic: returns {"tabId":N,"url":...}
|
||
$B closetab # close current
|
||
$B closetab 2 # close tab 2
|
||
$B tab-each "text" # run "text" on every tab, return JSON
|
||
```
|
||
|
||
`tab-each <command>` fans out a command across every open tab and returns a
|
||
JSON array — handy for "give me the text of every tab I have open."
|
||
|
||
### Frames
|
||
|
||
```bash
|
||
$B frame "#stripe-iframe" # switch to iframe by selector
|
||
$B frame @e7 # by ref
|
||
$B frame --name "checkout" # by name attribute
|
||
$B frame --url "stripe.com" # by URL pattern match
|
||
$B frame main # back to top frame
|
||
```
|
||
|
||
Refs are cleared on switch (the iframe has its own AX tree).
|
||
|
||
### State save/load
|
||
|
||
```bash
|
||
$B state save my-session # save cookies + URLs to .gstack/browse-state-my-session.json
|
||
$B state load my-session # restore
|
||
```
|
||
|
||
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
|
||
secrets to disk).
|
||
|
||
Manual save/load is one-shot. For state that survives daemon restarts
|
||
automatically, opt in with `BROWSE_PERSIST_STATE=1` in the daemon's
|
||
environment: the headless daemon snapshots cookies + per-tab
|
||
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600,
|
||
atomic writes) every 30 seconds and at clean shutdown, then restores it off
|
||
the boot path on the next launch. Default OFF — cookies on disk are a real
|
||
cost, so the user opts in. Headless only (headed mode's persistent Chromium
|
||
profile already owns its state). Loaded HTML and tab ownership are never
|
||
persisted, cookies for localhost, `.internal`, loopback IP literals
|
||
(127.0.0.0/8, `::1`), and link-local/cloud-metadata addresses
|
||
(169.254.0.0/16) are dropped on restore, and a corrupt snapshot is quarantined to
|
||
`session-state.json.corrupt` so persistence can never block a launch.
|
||
|
||
### Watch
|
||
|
||
```bash
|
||
$B watch # passive observation: snapshot every 5s while user browses
|
||
$B watch stop # return summary of what changed
|
||
```
|
||
|
||
Useful when you're driving the browser manually and want Claude to see what
|
||
you did at the end without spamming `snapshot` calls.
|
||
|
||
### Inbox
|
||
|
||
```bash
|
||
$B inbox # list messages from sidebar scout
|
||
$B inbox --clear # clear after reading
|
||
```
|
||
|
||
The sidebar scout (a background process the Chrome extension can spawn) drops
|
||
notes for Claude when the user surfaces something they want noticed. Stored
|
||
in `.gstack/browser-scout.jsonl`.
|
||
|
||
---
|
||
|
||
## CDP
|
||
|
||
### `$B cdp` — raw Chrome DevTools Protocol dispatch
|
||
|
||
Deny-default. Only methods enumerated in `browse/src/cdp-allowlist.ts`
|
||
(`CDP_ALLOWLIST` const) are reachable; any other method returns 403. Each
|
||
allowlist entry declares scope (tab vs browser) and output (trusted vs
|
||
untrusted). Untrusted methods (data-exfil-shaped, e.g.
|
||
`Network.getResponseBody`) get UNTRUSTED-envelope wrapped output.
|
||
|
||
```bash
|
||
$B cdp Page.getLayoutMetrics
|
||
$B cdp Network.enable
|
||
$B cdp Accessibility.getFullAXTree '{"depth":5}'
|
||
|
||
# Perf measurement on a simulated low-end client (overrides persist on the
|
||
# tab until you clear them — callers own restoration):
|
||
$B cdp Emulation.setCPUThrottlingRate '{"rate":4}' # clear: '{"rate":1}'
|
||
$B cdp Network.emulateNetworkConditions '{"offline":false,"latency":150,"downloadThroughput":195000,"uploadThroughput":97500}'
|
||
# clear: '{"offline":false,"latency":0,"downloadThroughput":-1,"uploadThroughput":-1}'
|
||
```
|
||
|
||
To discover allowed methods: read `browse/src/cdp-allowlist.ts`.
|
||
|
||
### `$B inspect` — CDP-based CSS inspector
|
||
|
||
```bash
|
||
$B inspect ".header" # full rule cascade for the header
|
||
$B inspect ".header" --all # include user-agent rules
|
||
$B inspect ".header" --history # show modification history
|
||
```
|
||
|
||
Returns the matched rule cascade with specificity, computed styles, the box
|
||
model, and (with `--history`) every CSS modification made via `$B style` since
|
||
the page loaded. Powered by a persistent CDP session per page in
|
||
`browse/src/cdp-inspector.ts`.
|
||
|
||
### `$B ux-audit`
|
||
|
||
```bash
|
||
$B ux-audit
|
||
```
|
||
|
||
Returns JSON with site identity, navigation, headings (capped 50), text
|
||
blocks, interactive elements (capped 200) — page structure for behavioral
|
||
analysis without dumping the full HTML. Used by `/qa` and `/design-review`
|
||
for cheap coverage maps.
|
||
|
||
---
|
||
|
||
## Performance
|
||
|
||
| Tool | First call | Subsequent calls | Context overhead per call |
|
||
|------|-----------|------------------|---------------------------|
|
||
| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) |
|
||
| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) |
|
||
| **gstack browse** | **~3s** | **~100-200ms** | **0 tokens** (plain text stdout) |
|
||
| **gstack browse + codified skill** | **~3s** | **~200ms** | **0 tokens** (single skill invocation) |
|
||
|
||
In a 20-command browser session, MCP tools burn 30,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–49151) |
|
||
| `/code/project-b` | `/code/project-b/.gstack/browse.json` | random (10000–49151) |
|
||
|
||
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–49151) | Fixed port for the HTTP server (debug override) |
|
||
| `BROWSE_IDLE_TIMEOUT` | 1800000 (30 min) | Idle shutdown timeout in ms |
|
||
| `BROWSE_STATE_FILE` | `.gstack/browse.json` | Path to state file |
|
||
| `BROWSE_SERVER_SCRIPT` | auto-detected | Path to `server.ts` |
|
||
| `BROWSE_CDP_URL` | (none) | Set to `channel:chrome` for real-browser mode |
|
||
| `BROWSE_CDP_PORT` | 0 | CDP port (used internally) |
|
||
| `BROWSE_HEADLESS_SKIP` | 0 | Skip Chromium launch entirely (test harness only) |
|
||
| `BROWSE_TUNNEL` | 0 | Activate the dual-listener tunnel architecture (requires `NGROK_AUTHTOKEN`) |
|
||
| `BROWSE_TUNNEL_LOCAL_ONLY` | 0 | Test-only — bind both listeners locally without ngrok |
|
||
| `GSTACK_BROWSE_MAX_HTML_BYTES` | 52428800 (50MB) | `load-html` size cap |
|
||
| `GSTACK_SECURITY_OFF` | unset | Emergency kill switch — disable ML classifier |
|
||
| `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. |
|
||
| `GSTACK_CDP_STEALTH` | unset | Set to `on`/`1`/`true` to emit `--gstack-suppress-prepare-stack-trace` (gbrowser Pack 2 / B11 C++ patch only; no-op on stock Chromium) |
|
||
| `GSTACK_GPU_VENDOR`, `GSTACK_GPU_RENDERER`, `GSTACK_GPU_CHIPSET` | unset | Per-install GPU spoof fed to the Pack 1 WebGL/UA-CH C++ patches. Set by gbd from the host profile; emitted as `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / `--gstack-ua-model` cmdline switches only when present. |
|
||
| `GSTACK_PLATFORM` | unset | Host platform classification (`MacARM`/`MacIntel` → `macOS`, `Win32` → `Windows`, `Linux*` → `Linux`) emitted as `--gstack-ua-platform` |
|
||
| `GSTACK_HW_CONCURRENCY`, `GSTACK_DEVICE_MEMORY` | host profile (fallback 8) | Per-install `hardwareConcurrency`/`deviceMemory` reported by Layer C and emitted as `--gstack-hw-concurrency` / `--gstack-device-memory` for the worker-navigator C++ patch |
|
||
|
||
---
|
||
|
||
## Source map
|
||
|
||
```
|
||
browse/
|
||
├── src/
|
||
│ ├── cli.ts # Thin client — reads state, sends HTTP, prints
|
||
│ ├── server.ts # Bun HTTP daemon — routes commands, dual-listener
|
||
│ ├── browser-manager.ts # Chromium lifecycle, tabs, ref map, crash detection
|
||
│ ├── port-allocator.ts # Fixed 10000-49151 scan range for every long-lived listener (never port:0)
|
||
│ ├── xprotect-heal.ts # macOS XProtect launch-kill classify + quarantine-clear + bounded reinstall
|
||
│ ├── socks-bridge.ts # Local 127.0.0.1 SOCKS5 bridge that handles auth handshakes Chromium can't speak
|
||
│ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both)
|
||
│ ├── proxy-redact.ts # Cred-redaction helper for any proxy URL surfaced to logs/errors
|
||
│ ├── xvfb.ts # Xvfb auto-spawn + orphan cleanup with PID + start-time validation
|
||
│ ├── stealth.ts # Layer C: webdriver mask + window.chrome.* + Notification/Permissions + per-install hardware + toString proxy + automation-global sweep; buildGStackLaunchArgs (GSTACK_* cmdline switches); GSTACK_STEALTH=extended opt-in
|
||
│ ├── browse-client.ts # Canonical SDK — what skills import as _lib/browse-client.ts
|
||
│ ├── snapshot.ts # AX tree → @e/@c refs → Locator map; -D/-a/-C handling
|
||
│ ├── read-commands.ts # Non-mutating: text, html, links, js, css, is, dialog, ...
|
||
│ ├── write-commands.ts # Mutating: goto, click, fill, upload, dialog-accept, ...
|
||
│ ├── meta-commands.ts # state, watch, inbox, frame, ux-audit, chain, diff, ...
|
||
│ ├── browser-skills.ts # 3-tier walk + frontmatter parser + tombstones
|
||
│ ├── browser-skill-commands.ts # $B skill list/show/run/test/rm + spawnSkill
|
||
│ ├── browser-skill-write.ts # D3 atomic stage/commit/discard helper for /skillify
|
||
│ ├── skill-token.ts # mintSkillToken / revokeSkillToken (per-spawn, scoped)
|
||
│ ├── domain-skills.ts # Per-site agent notes (state machine: quarantined→active→global)
|
||
│ ├── domain-skill-commands.ts # $B domain-skill save/list/show/edit/promote/rollback/rm
|
||
│ ├── cdp-allowlist.ts # Deny-default CDP method allowlist
|
||
│ ├── cdp-bridge.ts # CDP session lifecycle bridge
|
||
│ ├── cdp-commands.ts # $B cdp dispatcher
|
||
│ ├── cdp-inspector.ts # $B inspect — persistent CDP session per page
|
||
│ ├── activity.ts # ActivityEntry, CircularBuffer, SSE subscribers, privacy filtering
|
||
│ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring)
|
||
│ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope)
|
||
│ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens
|
||
│ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events
|
||
│ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth
|
||
│ ├── tunnel-denial-log.ts # ~/.gstack/security/attempts.jsonl writer (salted)
|
||
│ ├── path-security.ts # validateOutputPath / validateReadPath / validateTempPath
|
||
│ ├── url-validation.ts # URL safety checks for goto
|
||
│ ├── content-security.ts # L1-L3: datamarking, hidden strip, ARIA, URL blocklist, envelopes
|
||
│ ├── security.ts # L5 canary + L6 verdict combiner + thresholds
|
||
│ ├── security-classifier.ts # L4 ML classifier (TestSavantAI, runs in the security sidecar)
|
||
│ ├── security-sidecar-entry.ts # Sidecar subprocess entrypoint hosting the ONNX classifier
|
||
│ ├── security-sidecar-client.ts # server.ts-side client that drives the sidecar
|
||
│ ├── terminal-agent.ts # Side Panel Claude PTY manager (auth + lifecycle)
|
||
│ ├── sidebar-utils.ts # Sidebar URL sanitization + helpers
|
||
│ ├── cookie-import-browser.ts # Decrypt + import cookies from real Chromium browsers
|
||
│ ├── cookie-picker-routes.ts # HTTP routes for /cookie-picker/*
|
||
│ ├── cookie-picker-ui.ts # Self-contained HTML/CSS/JS for cookie picker
|
||
│ ├── network-capture.ts # Network request capture for $B network
|
||
│ ├── media-extract.ts # Media element extraction for $B media
|
||
│ ├── project-slug.ts # Project slug derivation for state paths
|
||
│ ├── error-handling.ts # safeUnlink / safeKill / isProcessAlive
|
||
│ ├── platform.ts # OS detection (macOS, Linux, Windows)
|
||
│ ├── telemetry.ts # Anonymous opt-in usage telemetry
|
||
│ ├── find-browse.ts # Locate running daemon or bootstrap
|
||
│ └── config.ts # Config resolution (env / files)
|
||
├── test/ # Integration tests + HTML fixtures
|
||
└── dist/
|
||
└── browse # Compiled binary (~58MB, Bun --compile)
|
||
|
||
browser-skills/
|
||
└── hackernews-frontpage/ # Bundled reference skill
|
||
├── SKILL.md
|
||
├── script.ts
|
||
├── _lib/browse-client.ts
|
||
├── fixtures/hn-2026-04-26.html
|
||
└── script.test.ts
|
||
|
||
scrape/SKILL.md.tmpl # /scrape gstack skill — match-or-prototype entry point
|
||
skillify/SKILL.md.tmpl # /skillify gstack skill — codify last /scrape into permanent skill
|
||
```
|
||
|
||
---
|
||
|
||
## Development
|
||
|
||
### Prerequisites
|
||
|
||
- [Bun](https://bun.sh/) v1.0+
|
||
- Playwright's Chromium (installed automatically by `bun install`)
|
||
|
||
### Quick start
|
||
|
||
```bash
|
||
bun install # install deps + Playwright Chromium
|
||
bun test # all integration tests (~3s for browse-only)
|
||
bun run dev <cmd> # run CLI from source (no compile)
|
||
bun run build # compile to browse/dist/browse
|
||
```
|
||
|
||
### Dev mode vs compiled binary
|
||
|
||
During development, use `bun run dev` instead of the compiled binary. It runs
|
||
`browse/src/cli.ts` directly with Bun, so you get instant feedback:
|
||
|
||
```bash
|
||
bun run dev goto https://example.com
|
||
bun run dev text
|
||
bun run dev snapshot -i
|
||
bun run dev click @e3
|
||
```
|
||
|
||
The compiled binary (`bun run build`) is only needed for distribution. It
|
||
produces a single ~58MB executable at `browse/dist/browse` using Bun's
|
||
`--compile` flag.
|
||
|
||
### Running tests
|
||
|
||
```bash
|
||
bun test # all tests
|
||
bun test browse/test/commands # command integration tests
|
||
bun test browse/test/snapshot # snapshot tests
|
||
bun test browse/test/cookie-import-browser # cookie import unit tests
|
||
bun test browse/test/browser-skill-write # D3 atomic-write helper tests
|
||
bun test browse/test/tunnel-gate-unit # canDispatchOverTunnel pure tests
|
||
```
|
||
|
||
Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML
|
||
fixtures from `browse/test/fixtures/`, then exercise the CLI against those
|
||
pages.
|
||
|
||
### Adding a new command
|
||
|
||
1. Add the handler in `read-commands.ts` (non-mutating) or `write-commands.ts`
|
||
(mutating), or `meta-commands.ts` (server / lifecycle).
|
||
2. Register the route in `server.ts`.
|
||
3. Add the entry to `COMMAND_DESCRIPTIONS` in `browse/src/commands.ts` (with
|
||
a clear `description` and `usage` — the `gen-skill-docs` validation
|
||
suite enforces no `|` characters in `description`).
|
||
4. Add a test case in `browse/test/commands.test.ts` with an HTML fixture
|
||
if needed.
|
||
5. Run `bun test` to verify.
|
||
6. Run `bun run build` to compile.
|
||
7. Run `bun run gen:skill-docs` to regenerate SKILL.md (the command appears
|
||
in the command-reference table downstream).
|
||
|
||
### Adding a new browser-skill
|
||
|
||
For a hand-written skill: copy `browser-skills/hackernews-frontpage/`,
|
||
update SKILL.md frontmatter, rewrite `script.ts` against your target site,
|
||
re-capture the fixture, update the parser test. `bun test` validates the
|
||
SKILL.md contract (sibling SDK byte-identity, frontmatter schema).
|
||
|
||
For an agent-written skill: drive the page once with `/scrape <intent>`,
|
||
say `/skillify`, accept the proposed name in the approval gate. The skill
|
||
lands at `~/.gstack/browser-skills/<name>/` after the test passes.
|
||
|
||
### Deploying to the active skill
|
||
|
||
The active skill lives at `~/.claude/skills/gstack/`. After making changes:
|
||
|
||
```bash
|
||
cd ~/.claude/skills/gstack
|
||
git fetch origin && git reset --hard origin/main
|
||
bun run build
|
||
```
|
||
|
||
Or copy the binary directly:
|
||
|
||
```bash
|
||
cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse
|
||
```
|
||
|
||
---
|
||
|
||
## Cross-references
|
||
|
||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — system-level architecture, dual-listener tunnel design, prompt-injection defense threat model
|
||
- [`CLAUDE.md`](CLAUDE.md) — project-level instructions, sidebar architecture notes, security-stack constraints
|
||
- [`docs/REMOTE_BROWSER_ACCESS.md`](docs/REMOTE_BROWSER_ACCESS.md) — operator guide for `/pair-agent` (setup keys, scoped tokens, denial log)
|
||
- [`docs/designs/BROWSER_SKILLS_V1.md`](docs/designs/BROWSER_SKILLS_V1.md) — design doc for browser-skills runtime (Phase 1 + 2a + roadmap)
|
||
- [`scrape/SKILL.md`](scrape/SKILL.md) — `/scrape` skill: match-or-prototype data extraction
|
||
- [`skillify/SKILL.md`](skillify/SKILL.md) — `/skillify` skill: codify last `/scrape` into permanent skill
|
||
- [`TODOS.md`](TODOS.md) — `/automate` (Phase 2b P0), Phase 3 resolver injection, Phase 4 eval + sandbox
|
||
|
||
---
|
||
|
||
## Acknowledgments
|
||
|
||
The browser automation layer is built on [Playwright](https://playwright.dev/)
|
||
by Microsoft. Playwright's accessibility tree API, locator system, and
|
||
headless Chromium management are what make ref-based interaction possible.
|
||
The snapshot system — assigning `@ref` labels to AX tree nodes and mapping
|
||
them back to Playwright Locators — is built entirely on top of Playwright's
|
||
primitives. Thank you to the Playwright team for building such a solid
|
||
foundation.
|
||
|
||
The prompt-injection L4 layer uses
|
||
[TestSavantAI/distilbert-v1.1-32](https://huggingface.co/TestSavantAI/distilbert-v1.1-32)
|
||
(112MB ONNX), run locally via `@huggingface/transformers`.
|
||
|
||
The CDP escape hatch is gated by an allowlist directly inspired by Codex's
|
||
T2 outside-voice review during the v1.4 design pass: deny-default with an
|
||
explicit allowlist, not allow-default with a denylist.
|