* fix(test): host-config goldens self-provision .agents/.factory artifacts
Fixes #2532. The codex/factory golden tests read gitignored artifacts that
only gen-skill-docs.test.ts (serial tree-mutating phase) produces, so the
file failed in isolation and on clean clones (the #2536 "3 failures then 0"
symptom). beforeAll now generates a host's artifacts iff its ship SKILL.md
is missing — never overwriting existing ones, so stale artifacts still fail
the golden. The file is also classified TREE_MUTATING so its provisioning
runs in the serial window, not racing parallel readers.
Verified: full pass with .agents/ and .factory/ deleted (74/74 in isolation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): exempt the live repo tree from hermetic-wiring's operator-~/.claude ban
The skill-seeding tripwire asserted every seeded symlink target must NOT
start with ~/.claude — but on the default global-git install the repo
itself lives at ~/.claude/skills/gstack, so every CORRECT symlink (which
must resolve into the live repo tree, as the very next assertion requires)
carried the banned prefix. The test could never pass on a default install:
pristine v1.64.1.0 (c118e240) fails it in any worktree under
~/.claude/skills/ and passes elsewhere (verified 2026-08-15).
Exempt targets that realpath into the resolved repo ROOT before applying
the operatorClaude ban — realpath both sides so a symlinked HOME can't
dodge the tripwire. Genuine escapes (a target under ~/.claude but outside
the repo) still fail with the escape message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gen-skill-docs): quote YAML inline scalars containing '...' (Bun strict parser breaks on bare ellipsis)
A bare ... inside a plain YAML scalar is a document-end marker that strict
YAML parsers (Bun.YAML among them) reject mid-scalar. catalog-trim truncation
appends '...' to any description whose lead exceeds 200 chars, so any
truncated description would generate a SKILL.md with unparseable frontmatter.
Add the ellipsis test to toYamlInlineScalar's needsQuote so such scalars are
emitted double-quoted, plus unit coverage for the quoting rules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gen-skill-docs): throw when a template contains {{PREAMBLE}} twice
Hardens the #2508/#2362 class: a second {{PREAMBLE}} occurrence — even a
prose mention, which is exactly how spec/SKILL.md.tmpl re-expanded the full
~12K-token preamble mid-document — now fails generation with the template
path instead of silently shipping a doubled preamble. Pure exported guard
(assertSinglePreamble) called from resolvePlaceholders, unit-tested with the
original prose-mention shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): classify catalog-trim.test.ts as tree-mutating
Discovered while landing the duplicate-{{PREAMBLE}} guard: importing
scripts/gen-skill-docs.ts executes its top-level body, which regenerates the
entire claude host (71 GENERATED files) at import time. catalog-trim.test.ts
does that import from a PARALLEL shard — the same read-during-regeneration
hazard class as #2532, invisible only because the regen is byte-identical on
a fresh tree. Move it to the serial tree-mutating window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): prepush hook test builds PATH with a POSIX-only separator
`test/redact-prepush-hook.test.ts` shadows `git` with a stub by prepending a
temp dir to PATH, built as `${stubDir}:${process.env.PATH}`. On Windows the
separator is `;`, so that produces one unparseable entry, the stub is never
found, and the REAL git runs — the diff succeeds, `gitStrict` never throws, and
the hook exits 0 where the test expects 1. It fails as a wrong assertion rather
than as a portability problem, which is what made it hard to place.
Replace it with a `prependPath` helper mirroring the one already in
test/gstack-brain-context-load.test.ts, which handles both platform details:
`path.delimiter`, and a case-insensitive lookup of the existing env key —
Windows commonly spells it `Path`, and adding a second `PATH` alongside an
inherited `Path` leaves the winner up to the spawn implementation.
On POSIX the helper resolves to `{ PATH: binDir + ":" + process.env.PATH }`,
byte-identical to the expression it replaces, so behaviour there is unchanged.
Fixing the separator alone does not make the test pass on Windows, and it
cannot: the premise is that a signal-killed child yields `spawnSync`
status === null, and Windows has no equivalent (a force-killed process reports
a non-zero exit code). The stub is also a `#!/bin/sh` file named `git`, which
Windows will not execute, since process creation resolves through PATHEXT and
ignores the shebang. A Windows variant would assert the non-zero-exit branch
instead — a different branch than the test name claims — so the test is gated
with test.skipIf(process.platform === "win32"), matching
test/session-runner-timeout.test.ts and test/setup-emoji-font.test.ts.
Windows before: 14 pass, 1 fail. After: 14 pass, 1 skip, 0 fail (3 consecutive
runs). Unchanged on POSIX, where it should still run and pass — worth
confirming in CI, since I can only verify the Windows half here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(artifacts): sync the decision store, which no allowlist glob matched
gstack-decision-log enqueues projects/<slug>/decisions.jsonl after every write,
but none of the 16 managed globs matched it, so compute_paths_to_stage rejected
every one at its "must match at least one allowlist glob" check.
The writer and the syncer disagreed silently: enabling artifacts sync backed up
learnings, plans, designs and timelines -- everything except the durable decision
ledger -- and nothing reported a miss, because a dropped path prints exactly what
a synced one does when the queue is otherwise empty.
Add the three decisions.* globs and class them artifact so they also sync in
artifacts-only mode.
The test reads the heredocs out of the script rather than executing it:
gstack-artifacts-init.test.ts drives the real script through #!/bin/bash shims and
a colon-separated PATH, so it cannot run on Windows -- the platform where the
companion slug bug bit.
* fix(windows): resolve the project slug natively when gstack-slug cannot spawn
bin/gstack-slug is a `#!/usr/bin/env bash` script with no file extension. Windows
honors neither the shebang nor PATHEXT for an explicit path, so spawnSync fails
ENOENT and resolveSlug returned its literal fallback, "unknown".
Every decision on the machine was therefore filed under
~/.gstack/projects/unknown/ -- one bucket shared by every project -- while the
bash-side Context Recovery preamble resolved the real slug, found no
decisions.active.json there, and skipped through a bare `if [ -f ... ]` with no
else.
Nothing failed. Both decision bins (log and search) missed identically, so writes
and searches stayed consistent with each other, and the only component that
resolved correctly was silent by design. Measured on one machine: 62 decisions
accumulated over 10 days and 170 skill runs, surfaced zero times.
shell:true is not the fix here, unlike #1731 -- cmd.exe cannot run a bash script
either. Nor is re-spawning through `bash`: on Windows that frequently resolves to
WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
directory, trading one split store for another.
Instead, port gstack-slug's own three steps (cache -> git remote -> basename),
keeping its alphabet and its MSYS-form cache key so both paths agree. The
fallback is win32-gated, so POSIX behaviour is byte-identical.
Tests exercise the fallback on every platform (only the gating is win32-specific),
so POSIX CI catches a regression that would otherwise surface only on a Windows
user's disk, plus a static gate pinning the platform check.
* fix(security): guard brain-sync arithmetic against injected .brain-last-pull; sanitize _GBRAIN_HOST
Re-derived from PR #2588 under the generated-file screening rule (resolver
hunks taken; SKILL.md files regenerated, not accepted). A poisoned
.brain-last-pull could reach bash arithmetic ($(( ))) — a code-execution
vector from a writable state file; the timestamp is now validated numeric
before use. _GBRAIN_HOST from ~/.claude.json is clamped to hostname-safe
characters before echo. Ship goldens refreshed to the regenerated output.
Co-authored-by: sneakygriff <89592870+sneakygriff@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): run gstack-brain-sync through bash, not cmd.exe, on Windows
The brain-sync stage failed on EVERY Windows run with "is not
recognized as an internal or external command", so /sync-gbrain always
reported ERR brain-sync among otherwise green stages.
#1731 gave these spawns shell: NEEDS_SHELL_ON_WINDOWS. That is correct
for the gbrain.cmd shim and does nothing here: shell:true routes through
cmd.exe, which resolves .cmd/.bat via PATHEXT but has no concept of a
shebang, so an extension-less bash script is rejected outright. A .cmd
shim needs a shell; a shebang script needs an interpreter. The two cases
look identical and are not.
The failure was quiet rather than loud. artifacts_sync_mode defaults to
pushing curated artifacts to git, so a Windows user's learnings piled up
uncommitted in ~/.gstack indefinitely while the sync report showed one
red line out of four.
New bashScriptInvocation() resolves Git for Windows' bash explicitly and
passes the script as argv[0]. It prefers Git bash over a bare `bash` on
PATH because WindowsApps ships a bash.exe that is the WSL launcher, which
would read C:\... as a Linux path; GSTACK_BASH overrides for unusual
installs; forward slashes because bash treats backslashes as escapes; and
it returns null when no bash exists so the stage says so plainly instead
of surfacing an unactionable spawn error.
The #1731 tripwire asserted the shape that does not work, so it now
asserts the opposite (never a raw spawnSync(brainSyncPath, ...)) and six
unit tests cover the resolver.
Verified on Windows: the stage now reports "OK brain-sync curated
artifacts pushed (4.2s)" and the artifacts repo committed + pushed on its
own. Affected-test set unchanged at 14 pre-existing failures before and
after, with 6 new passing tests.
* fix(gbrain): quote cmd.exe arguments at a single gbrain invocation seam
Fixes #2471. With shell:true on Windows, node/bun join argv into one cmd.exe
string without quoting, so a repo path with a space — the default
C:\Users\First Last\ layout — split into two arguments and every gbrain call
carrying a path silently targeted the wrong location (worst: `sources add
--path`). All gbrain CLI invocations now build their (cmd, argv, shell)
triple through gbrainInvocation(), which quotes risky arguments for cmd.exe's
re-parse (embedded quotes doubled). The four direct spawn sites in
lib/gbrain-sources.ts route through the seam; the #1731 static invariant is
upgraded for seamed files (any direct "gbrain" opener is the violation) and
kept as-is for lib/gbrain-local-status.ts. POSIX behavior unchanged
(shell:false, passthrough argv).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(brain-sync): classify queue entries, rewrite surgically, re-push stranded commits
Fixes #2549 (P0 data loss). Every drain exit previously truncated the WHOLE
queue (six `: > "$QUEUE"` sites), which (a) destroyed privacy/mode-held
entries while misattributing them as "no allowlisted changes", (b) destroyed
entries enqueued concurrently during the drain, and (c) left push-failed
commits stranded locally with nothing ever re-pushing them until unrelated
new work arrived.
Now: compute_paths_to_stage classifies every entry (stageable / retained
privacy-held / dropped skipped-invalid-unmatched-missing); rewrite_queue
re-reads the LIVE queue at mv time and removes only this drain's processed
paths (retained + concurrent appends + unparseable lines survive; atomic
tmp+mv); an unpushed-commit detector at run start re-pushes stranded local
commits (receipted fail-closed; a receipt refusal skips the retry rather
than wedging the drain; guards missing origin/<branch>; runs inside the
existing lock). Status lines carry counts; full drop paths go to a 0600
sidecar (.brain-sync-drops.json) so filenames stay out of transcripts.
--drop-queue remains the one intentional truncation.
Matrix added: privacy retention, unmatched/missing counted drops + sidecar
mode, unparseable-line preservation, surgical same-drain retention, push-fail
commit retention + detector re-delivery on an EMPTY queue, receipt-refusal
skip. 35/35 in test/brain-sync.test.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gbrain): make --full do a full code walk, not a delta one
`runCodeImport()` walked with a bare `gbrain sync --strategy code --source X`.
The strategy is right, but that walk is incremental: it only revisits files
changed since the source's checkpoint. A file missed at the ORIGINAL import is
therefore never revisited and stays out of the index indefinitely.
The reindex-code pass below cannot rescue it. It re-chunks pages that already
exist and never walks the filesystem — the same property the comment directly
above already relies on when explaining why the walk has to run first. That fix
landed one flag short: it made a fresh source get pages at all, but left
`--full` unable to discover a file the first walk skipped.
Net effect: `/sync-gbrain --full` did not perform a full walk, and re-running it
never re-detected the gap.
The failure is silent, which is what makes it expensive. Nothing errors, nothing
warns, and the verdict block still reports OK while `gbrain search` and
`gbrain code-def` answer out of a partial index. It reads as "gbrain is weak at
code questions" rather than "the index is incomplete".
Measured on two local code sources before and after this change, counting
exported functions resolvable via `gbrain code-def`: one went from 61/201 (30%)
to 180/201 (89%), importing 79 files that had no page at all; the other had
whole source files missing entirely and reached 93%. Both had been serving
search from a partial index for weeks.
Scoped to `--full` so incremental runs stay fast. `--yes` because this spawns
non-interactively and a full walk otherwise prompts to confirm import cost.
Anyone can check their own brain without applying this:
gbrain sync --source <id> --strategy code --full --dry-run
and compare "N file(s) would be imported" against that source's page_count.
Worth knowing while doing so: the default strategy is markdown and --strategy
is per-invocation, never persisted on the source, so dropping the flag reports
strategy=markdown and a handful of files.
* fix(brain-cache): honest 'missing' instead of fabricated-empty digests on gbrain failure
A gbrain-unreachable failure in fetchRecentDecisions and fetchSalience
used to be converted into a cached 'successful' empty digest ("_No prior
skill runs recorded._" / "_No salient pages in last 14d._") that
refreshEntity stamped with last_refresh. The false negative then
survived every subsequent TTL cycle, indistinguishable from a genuine
zero-rows result. Now failure returns null, so cmdGet's existing
missing/stale-fallback machinery reports the true state — matching what
fetchGoals and fetchSimplePage already do on failure.
Also adds an Array.isArray guard in fetchRecentDecisions so a malformed
payload ({pages: {}} etc.) classifies as failure instead of crashing
refreshEntity mid-refresh; a genuinely empty pages array still renders
the honest empty digest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): give the schema-mismatch rebuild test a load-proof budget
The rebuild path refreshes every per-project entity against the real gbrain
CLI; with an unreachable brain each spawn runs to its own timeout, and under
machine load the stack exceeds bun's 5s default (observed 5.2-5.4s,
identically on pre-#2587 binaries — a load flake, not a regression). 30s
budget matches the sibling brain-sync suite's convention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(memory-ingest): parse the current Codex response_item rollout shape
Fixes #2105. Codex rollout JSONL moved to
{ type: 'response_item', payload: { type: 'message', role, content: [...] } };
the parser's legacy payload.message branch never fired on it, so every Codex
session imported as an empty shell (message_count: 0 — 243/243 sessions on
the reporting machine). Both shapes now parse; non-message response_items
(reasoning etc.) are ignored. parseTranscriptJsonl exported for direct unit
tests (CLI path unchanged — import.meta.main guard).
Note: #2104's staging-in-gitignored-tree half is already defended on main
(--include-gitignored + GIT_CEILING_DIRECTORIES, #2144, plus the #2486
reconcile guard) — verified, no change needed; it moves to the close-only
roster.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): refresh codex/factory ship goldens from post-#2588 regeneration
The #2588 absorb refreshed all three ship goldens, but `bun run
gen:skill-docs` regenerates the CLAUDE host only — the codex/factory goldens
were copied from artifacts rendered before the resolver change and failed
against a fresh external-host regen in the serial test phase. Re-rendered
with --host codex / --host factory and re-copied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(make-pdf): boolean flags no longer swallow the next positional argument
Fixes #2514. The parser treated any non-flag token after a flag as its value,
so `$P generate --toc essay.md` ate essay.md as --toc's value and failed with
"missing input" — the skill's own documented usage only worked when two
boolean flags happened to be adjacent. BOOLEAN_FLAGS enumerates the no-value
flags; value flags (--watermark, --to, --title, ...) are unchanged. main()
now runs behind import.meta.main so tests import the parser directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(repo-mode): probe GNU stat before BSD so Git Bash stops crashing
Fixes #2195. On GNU coreutils `stat -f` SUCCEEDS (filesystem status, not a
format string), so the BSD-first fallback chain never fell over — it fed
multi-word filesystem output into the cache-age arithmetic and crashed under
set -u on Windows Git Bash. GNU `stat -c` fails cleanly on BSD/macOS, making
GNU-first deterministic on both; the mtime is numeric-validated before
arithmetic as a last line of defense.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(retro): point the prior-retros context query at files /retro actually writes
Fixes #2552's live half. The gbrain context-query glob targeted
~/.gstack/projects/<slug>/retros/*.md — a directory and extension nothing
writes — so prior-retro recall was dead on every brain-aware run. /retro
saves to .context/retros/*.json (repo-local); the query now reads that. The
issue's second defect (quoted-tilde orphan sweep) is already fixed on main —
the preamble sweeps with "$HOME/..." — verified, no change needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync-gbrain): remove the capability-check page file left in the user's repo
Fixes #2503. On worktree-pinned brains `gbrain put` materializes the checked
page as _capability_check_<pid>.md in the current directory (the user's
repo), and `gbrain delete` removes the page but not the file — every
/sync-gbrain run left a stray file in the repo root. The check now deletes
the materialized file explicitly after the page delete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(browse): warn that hover scrolls and the daemon tab persists across sessions
Fixes #2445. Both behaviors are by design but produced confidently wrong
verification output: hovering a below-the-fold element scrolls the page
before a "rest state" screenshot (exit 0, wrong section), and the daemon's
tab survives sessions so a bare `reload` can act on whatever earlier work
left open. The screenshot-evidence section now names both traps with the
concrete guards (assert window.scrollY; always goto before verifying).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gitattributes): pin *.txt to LF
.gitattributes pins LF for every other text format in the repo (*.md,
*.tmpl, *.yml, *.yaml, *.json, *.toml, *.sh, *.ts, extensionless scripts,
even the hash-pinned diagram-render dist files). *.txt is the one text
format left unpinned.
On Windows with core.autocrlf=true, that means the two tracked .txt files
are rewritten to CRLF at checkout and then read as permanently modified:
gstack/llms.txt +174 bytes
make-pdf/test/fixtures/combined-gate.expected.txt +20 bytes
git status is never clean, and /gstack-upgrade's 'git stash' step saves a
phantom stash on every upgrade — one that pops back to an empty diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(setup): install every skill runtime asset for the Claude host
On a fresh Claude install, link_claude_skill_dirs installed only SKILL.md
(+ sections/) per skill. Every skill that reads a sibling runtime file at
.claude/skills/<name>/<file> was broken out of the box: /review stopped at
'Read .claude/skills/review/checklist.md' (file never installed), and qa's
templates/references, plan-devex-review's dx-hall-of-fame.md,
gstack-upgrade's migrations/, and careful/freeze's bin/ hooks were all
silently missing. Codex/Factory/OpenCode/Kiro installers already copied
these; the primary host never did.
Fix: a shared _link_skill_runtime_assets helper installs EVERYTHING a skill
ships next to its SKILL.md, with an explicit exclusion list (F7):
node_modules, dist, test, *.tmpl, hidden files. Exclusion-list polarity
means a newly added asset installs by default instead of being silently
dropped. Assets refresh unconditionally on re-run (rm + relink/copy), so
Windows real-dir copies pick up changes after git pull.
New free test runs the real installer functions against the live repo into
a temp skills dir with a TWO-CLASS referenced-paths assertion (ENG-OV7):
alias-relative refs (.claude/skills/<name>/<path>) must exist under the
install; repo-anchored refs (~/.claude/skills/gstack/<path>) must exist in
the tree modulo an explicit built-artifact allowlist (browse/design/
make-pdf dist + the compiled gstack-global-discover). Known-broken class-2
refs (#2250 bare bin names) are ratcheted: the test fails if they quietly
start existing without the entry being removed.
Fixes #2317
Fixes #2454
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): alias skills install as rewritten copies, never symlinks
The two back-compat alias dirs — _gstack-command (root router) and
connect-chrome (→ open-gstack-browser) — symlinked the canonical SKILL.md
verbatim, so each alias re-served the canonical frontmatter name:. Claude
Code keys skills on that name and requires global uniqueness: the
connect-chrome duplicate silently shadowed /open-gstack-browser (whichever
readdir returned first won), and the _gstack-command duplicate could drop
the ENTIRE personal-skills set — every /gstack command vanished until the
user hand-deleted the alias dirs, and the next setup re-broke it.
Fix: copy-then-rewrite. A shared _install_alias_skill_md helper reads the
SOURCE SKILL.md and writes a fresh copy with name: rewritten to the alias
dir's own name (_gstack-command / connect-chrome / gstack-connect-chrome).
sed never edits in place: on Unix the old install was a symlink into the
repo, and an in-place rewrite through it would have corrupted the generated
source (eng review E2). bin/gstack-relink gets the same treatment for its
root-alias helper, and its discovery loop now skips symlinked source dirs
so the connect-chrome repo symlink can't re-mint the duplicate.
Tests assert: installed aliases are NOT symlinks, carry their own unique
names, all installed frontmatter names are globally unique, re-runs refresh
cleanly, legacy symlinked aliases are replaced not written through, and the
source files stay byte-intact.
Fixes #2511
Fixes #2201
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): Windows re-runs refresh installed skills for codex/factory/opencode hosts
On Windows (Git Bash / MSYS2, no Developer Mode), _link_or_copy installs
REAL directory copies. The install guards in link_codex_skill_dirs,
link_factory_skill_dirs, link_opencode_skill_dirs, and create_agents_sidecar
only ran the copy when the target was a symlink or missing — true on the
first install, never again. Every subsequent ./setup after a git pull
reported 'gstack ready (codex).' and exited 0 while silently refreshing
nothing: users ran stale SKILL.md forever. (link_claude_skill_dirs already
handled this; the other hosts never got the treatment.)
Fix: all five guard sites bypass the symlink-or-missing check when
IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the real-dir
copy refreshes in place. Unix behavior is unchanged (symlinks still pass the
guard via -L and serve updates without re-copying).
The new bash-fixture test drives the REAL extracted functions through the
install → upstream change → re-run cycle under IS_WINDOWS=1 (v1 must become
v2), pins the sidecar-skip behavior, checks the Unix path stayed a symlink,
and statically asserts the bypass at all five sites so factory/opencode
can't regress. Registered in the Windows-safe curated list
(KNOWN_WINDOWS_SAFE) so it actually runs on the windows-latest CI lane —
the 'bin/' pattern hit is a fixture path segment, not a shebang spawn.
Fixes #2444
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(uninstall): remove real-directory skill installs, gated on provenance
On Windows, setup installs skills as REAL directory copies (cp -R via
_link_or_copy). gstack-uninstall's per-skill loop filtered on [ -L ], so
every copy was skipped: --force exited 0 and printed 'gstack uninstalled.'
while leaving ~52 gstack-* directories plus _gstack-command/ behind in
~/.claude/skills. The same filter also missed the standard Unix shape (real
dir + symlinked SKILL.md), which was left as a dangling-symlink husk.
Fix: the loop now handles all three install shapes. Symlink entries keep
the existing readlink check. Real dirs with a SYMLINKED SKILL.md are removed
when the link points into gstack (same semantics as setup's cleanup
helpers). Real dirs with a REAL-FILE SKILL.md — the Windows copy shape — are
removed ONLY when both provenance gates pass (F8): (a) the directory name is
in gstack's skill inventory (source dir names, frontmatter names, gstack-
prefixed variants, and the alias dirs), and (b) the SKILL.md carries the
existing generated banner '<!-- AUTO-GENERATED from' (ENG-OV10: every
pre-v1.67 copy already carries it; a NEW marker would refuse to delete
legitimate old installs, recreating the bug). Anything failing a gate is
listed to stderr and never deleted — a user's own skill that happens to
share a name with a gstack skill survives.
Tests: a fake-tree fixture covers removed/kept/listed for every shape
(including the F8 name-collision row), and a census test asserts every
installable skill's generated SKILL.md carries the banner so the gate can't
strand a bannerless skill. Registered in the Windows-safe curated list —
the copy shape is exactly what windows-latest exercises.
Fixes #2563
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(setup): wire --host cursor through the full install path
'./setup --host cursor' was accepted by the flag parser and then did
nothing: no INSTALL_CURSOR branch existed, so the script built binaries,
printed no 'ready' line, and installed zero skills — Cursor users had no
way to install gstack at all.
Full install slice, re-derived from PR #2547 by @szsunyuan onto the
current installers: generate .cursor/ skill docs (host config already
existed), create a minimal ~/.cursor/skills/gstack runtime root (root
SKILL.md + bin/lib/browse assets + review checklist pair + ETHOS.md +
supabase config — bin and lib travel together because bin scripts import
../lib), link the generated gstack-* skills, and plant the repo-local
.cursor/skills/gstack sidecar WITHOUT ever wiping the generated SKILL.md
files it shares a directory with (link-before-sidecar ordering keeps the
generation fallback alive). Auto mode detects Cursor via the cursor
binary or the ~/.cursor footprint. gstack-uninstall removes
~/.cursor/skills/gstack* and per-project .cursor/skills/gstack* — and
never rmdir's .cursor itself, where Cursor stores user rules.
Re-derivation deltas from the PR: the link guards carry the #2444
IS_WINDOWS bypass (re-runs refresh real-dir copies), lib/ and
supabase/config.sh ride along like every other runtime root, and the
hosts/cursor.ts sidecar field is omitted (HostConfig no longer carries
one — sidecar behavior lives in setup).
Fixes #1358
Co-authored-by: Yuan Sun <forrest.sun527@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): include command in add-event dedup key (#2382)
Fixes #2382.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(setup): render the gbrain :user variant to an out-dir — global installs stay git-clean
On a global-git install with gbrain, ./setup and 'gstack-config
gbrain-refresh' ran gen:skill-docs:user IN PLACE inside the install
checkout, rewriting ~16 TRACKED SKILL.md files. The checkout stayed
permanently dirty, every /gstack-upgrade 'git stash' saved a redundant
snapshot of generated content, and the growing stash list invited a 'git
stash pop' that would lay stale instruction markdown from an older gstack
over the current version — a quiet wrong-rules failure mode.
Fix, wired through machinery that already existed (gen-skill-docs
--out-dir + the symlink install layer): brain-aware SKILL.md now renders
into the untracked ~/.gstack/render/claude, and both Claude installers
serve the render when present — setup's link_claude_skill_dirs prefers
$GSTACK_HOME/render/claude/<skill>/SKILL.md, and bin/gstack-relink does
the same so a later config change can't silently flip skills back to the
blockless canonical source. setup wipes and rebuilds the render each run,
repoints installed skills after a successful render, and removes a stale
render (re-linking canonical) when gbrain is gone. gbrain-refresh renders
to the out-dir and repoints via relink; its 'this dirties the install's
git tree' caveat is retired because it no longer does.
A one-time upgrade migration (gstack-upgrade/migrations/v1.67.0.0.sh, F12)
restores the legacy dirt: unstaged modifications to SKILL.md / sections/
*.md files in the install checkout are git-checkout'd back to canonical;
anything outside that footprint (user edits, untracked files, staged work)
is left alone and reported. Idempotent, non-fatal, symlinked installs
skipped.
Tests: render-preference behavior for both installers, static pins that
every executable :user invocation carries --out-dir and the caveat text is
gone, migration fixture (restore/leave/idempotent/no-op matrix), and the
existing out-dir render test now asserts 'git status --porcelain' gains
zero new entries across a full :user render.
Fixes #2569
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact): close the remaining #1946 fail-opens — detection coverage + one-time consent
Two of #1946's reported gaps were still open after the v1.64 fail-closed
work (the git-error and oversized-diff paths in bin/gstack-redact-prepush
are already strict, chunked, and pinned by tests):
1. Detection fail-open: env.kv required an UPPERCASE name with an '='
assignment, so 'api_key=…', 'apiKey: "…"', and 'password: …' — the
most common real config shapes — produced NO finding at all. The pattern
is now case-insensitive, accepts ':' (YAML/JSON) as well as '='
assignment, and handles quoted JSON keys. It stays MEDIUM and
entropy-gated per the calibration rule (a generic net that cries wolf
gets bypassed), with pinned cases for each closed shape plus the
placeholder/entropy negatives.
2. Install fail-open: nothing ever offered the guard, so a plain 'git
push' scanned nothing and users believing themselves protected weren't.
setup now asks ONCE for consent on a real interactive terminal
(maintainer decision 6): an explicit answer is recorded to the existing
redact_prepush_hook key and never re-asked; a timeout or non-interactive
run changes nothing and keeps the hint-only posture. Default stays
FALSE, and setup still never installs the hook itself — /ship owns the
per-repo install (the wrong-repo invariant is pinned by the existing
'setup carries the hint only' test).
Tests: per-shape pattern cases, prompt gating statics (key-absence + TTY +
timed default-N read), timeout-persists-nothing, non-interactive stays
hint-only with no key write, and recorded-answer-is-silent behavior runs.
Contributes to #1946 (the pre-push guard's fail-closed scan paths landed
in earlier releases; this closes the coverage and consent gaps it names).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(hooks): Stop hook closes dangling timeline entries — fail-open
The preamble writes event:'started' to the project timeline at every skill
start, but the matching 'completed' write lives in prose at the END of the
skill workflow — unenforceable. An interrupted session, a context blowout,
or an agent that simply stops leaked started > completed forever, and the
leak was unrepairable after the fact (observed live in #2553).
New hosts/claude/hooks/timeline-stop-hook (+ .ts, question-log-hook shim
pattern): on Claude Code's Stop event it appends event:'completed' with
outcome 'unknown' and source 'stop-hook' for every 'started' entry in the
project timeline that has no matching completion. setup registers it via
gstack-settings-hook add-event (Stop was already an accepted event) under
its own source tag, idempotently; --no-team and gstack-uninstall remove it.
FAIL-OPEN contract (F5), pinned by tests: ALWAYS exits 0 — corrupt
timeline (bad lines skipped individually, valid ones still repaired),
missing timeline, garbage/empty stdin, bun missing from PATH (the shim
'|| true's), and an over-cap timeline (10MB skip) all repair nothing and
block nothing; errors land in ~/.gstack/hook-errors.log best-effort. The
write path is append-only with a ~2s internal budget, and a second Stop is
a no-op (already-closed entries never re-close). Correlation is
project-scoped by design — the preamble's session id is shell-local, so a
concurrent same-project session's entry may close early as a traceable
source:'stop-hook' row rather than a silent leak; the header documents the
trade-off.
Fixes #2553
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios-qa: guard DebugBridgeTouch.m on DEBUG, not just TARGET_OS_IOS
DebugBridgeTouch.m and its header both promise the code is DEBUG-only and
never shipped:
"Uses these private UIKit selectors (DEBUG-only; never shipped to App Store)"
"DEBUG-only — never link in Release."
Nothing enforced it. The only guard was `#if TARGET_OS_IOS`, so a Release build
for iOS compiled the entire implementation in, private API and all.
Measured on a real app (an iOS Release build, `nm -j` on the app binary):
DebugBridge symbols 15
IOHIDEventCreateDigitizer 2
AXSSetAutomationEnabled 1 symbol, 2 strings
IOKit.framework 4 strings
including +[DebugBridgeTouch sendTapAtPoint:inWindow:] and
_OBJC_CLASS_$_DebugBridgeTouch. That is a Guideline 2.5.1 private-API exposure
in a shippable binary, and it fails Package.swift's own stated CI invariant:
nm -j build/Release/<binary> | grep -q DebugBridge && exit 1
WHY THE EXISTING GUARD DOES NOT COVER THIS
Package.swift documents the protection as `.when(configuration: .debug)` on the
consuming target's dependency. That works for SwiftPM consumers. It cannot be
expressed by an app that integrates DebugBridge as a local package inside an
.xcodeproj: Xcode's Filters column under Frameworks, Libraries, and Embedded
Content offers platform conditions only — iOS, macOS, visionOS — never build
configuration. So for xcodeproj consumers the documented guard silently does
nothing, which is precisely the case that was measured.
The Swift targets were already safe: all four .swift files are `#if DEBUG`
guarded and Package.swift defines DEBUG for them via swiftSettings. Only the
Objective-C target, the one that actually links private API, was unguarded.
THE FIX
1. DebugBridgeTouch.m.template now branches `#if !defined(DEBUG)` first and
emits nothing at all in Release, falling through to the existing iOS and
non-iOS branches only in Debug.
2. Package.swift.template declares DEBUG explicitly for the ObjC target:
cSettings: [.define("DEBUG", .when(configuration: .debug))]
The two Swift targets already did this. Relying on SwiftPM's implicit DEBUG
for C-family targets is not worth betting a private-API exposure on.
VERIFIED, by compiling the generated file for iOS both ways:
xcrun -sdk iphoneos clang -c DebugBridgeTouch.m -arch arm64 ...
Release (no -DDEBUG) 0 DebugBridge symbols, 0 private-API symbols, 448 B
Debug (-DDEBUG=1) 7 DebugBridge symbols, 6 private-API symbols, 13104 B
The harness is unchanged in Debug. Release now emits an empty translation unit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ios-qa): bridges search front-most presented content first
A presented sheet sits AFTER the screen it covers in window.subviews, so
the elements walk emitted the covered screen first — a client taking the
first match for a label activated a control the user cannot reach, and
the agent saw a success (measured on a real app: the sheet's 'Create'
button ranked 210th behind 35+ covered-screen entries). Menus, alerts and
action sheets were worse: each gets its OWN UIWindow, so keying off
isKeyWindow missed them entirely — absent from /elements, dropped from
/screenshot, untappable via /tap.
Re-derived from PR #2397 by @IDSTUK onto the current bridge templates
(the SwiftUI tap-reliability rework had moved underneath the PR):
ScreenshotBridgeImpl gains orderedWindows(in:) (visible windows front-most
first by windowLevel then insertion order, PassThroughWindow overlays
still filtered), frontmostWindow(), and searchRoots() (per window, the
top-most presented view controller's view before the window itself).
/elements walks those roots in order through the existing shared
visited-set + budget, so overlapping roots emit each view once at its
front-most position; /tap targets frontmostWindow() for both the
accessibility-activation and synthesized-touch paths; /type and /swipe
search the roots in order; /screenshot composites every window
back-to-front at the existing 1x scale. The two now-dead private
activeScene/activeKeyWindow copies in ElementsBridgeImpl and
MutationBridgeImpl are removed.
Fixture mirror synced byte-for-byte; verified with a full
'xcodebuild build -scheme FixtureApp-Package -destination
generic/platform=iOS Simulator' (BUILD SUCCEEDED, DEBUG guard from the
previous commit included).
Co-authored-by: IDST UK <IDSTUK@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup-gbrain): invoke gstack-memory-ingest/gstack-gbrain-sync via bun run + .ts
/setup-gbrain's transcript-ingest steps told the agent to run
bin/gstack-memory-ingest and bin/gstack-gbrain-sync by BARE name. Neither
exists — only the .ts files ship (mode 644, no bin alias) — so the agent
dutifully reported 'script missing at install root' and the ingest/full-
sync steps dead-ended on every host (hit live under Codex; the Claude
render carries the same text).
All four template sites (probe, silent-bulk, post-answer full sync, the
preamble-hook incremental mention) and the four memory.md reference-doc
sites now use the repo's established form: 'bun run <path>/gstack-memory-
ingest.ts …' / 'bun run <path>/gstack-gbrain-sync.ts …' — matching what
sync-gbrain already does. Generated SKILL.md regenerated from the template
in the same commit.
Re-derived from PR #2409 by @SomSamantray per the wave's screening rule
(the PR edited the generated SKILL.md directly; the generated file must
come from gen:skill-docs). The contributor's structural test rides along
as-is: bare-invocation regexes with negative .ts lookahead and backslash-
continuation coverage pin every site, so the drift can't return. The
referenced-paths ratchet in test/setup-claude-skill-assets.test.ts drops
its two #2250 known-broken entries — the class-2 assertion now guards
these paths again.
Verified against #2250's site list (template lines 690/735/784-area, all
covered) plus a fresh grep: zero bare invocations remain in the template
or memory.md; the one prose mention ('gstack-memory-ingest now persists…')
is not an invocation and stays.
Fixes #2250
Fixes #2393
Co-authored-by: SomSamantray <SomSamantray@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): update four main-side assertions to the T3 installer contracts
Integration drift from the T3 lane: three static assertions pinned the OLD
implementation shapes that T3 legitimately replaced — the gbrain-refresh
branch no longer self-documents a reset --hard cycle (#2569 renders to an
untracked out-dir instead; the test now pins THAT), setup's regen block
renamed to the render form (re-anchored, same exit-code-propagation
invariant), and sections/ linking generalized into _link_skill_runtime_assets
(the _link_or_copy routing assertion moved into the helper). Fourth: the
uninstall neutral-target test asserted against os.tmpdir(), which reads
$TMPDIR at call time — a shard neighbor can leave it gstack-containing,
making the "neutral" symlink target match the provenance substring; the test
now falls back to a fixed neutral root and asserts neutrality explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: whitelist engine-locked at all three gbrain-usable gates (#2456)
#2194 taught the classifier to report a PGLite lock held by a live
\`gbrain serve\` as engine-locked instead of broken-config, but none of the
three "is gbrain usable?" gates accepted the new status — so the symptom
moved from a wrong error to a quieter wrong suppression: gbrain-refresh
stripped GBRAIN_CONTEXT_LOAD / GBRAIN_SAVE_RESULTS blocks out of every
generated SKILL.md after every upgrade, on the RECOMMENDED /setup-gbrain
default (PGLite + local-stdio MCP spawns gbrain serve at session start).
engine-locked is the same class as timeout (#1964): the engine is
installed and healthy, a legitimate holder has the lock. All three gates
now agree:
- bin/gstack-gbrain-detect --is-ok exits 0 on engine-locked
- bin/gstack-config gbrain-refresh case arm renders instead of suppressing
- scripts/gen-skill-docs.ts --respect-detection treats it as detected
Test mirrors the existing timeout case in
test/gbrain-detection-override.test.ts (engine-locked renders brain
blocks; the sibling no-cli case still proves suppression works).
Applies the reporter's patch + test from the issue.
Fixes #2456
Co-authored-by: Mateus Moraes <mmoraes@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: detect bearer-token thin clients via host MCP registration (#2520)
The #2051 thin-client fix keys detection on the remote_mcp marker in
~/.gbrain/config.json — but that marker is only written by the OAuth path
(gbrain init --mcp-only). Bearer-token installs (gbrain connect <url>
--token, gbrain's own recommended default for local/personal use) never
touch config.json, so they fell through to the local probe, failed against
the dead-or-absent local engine, and landed on missing-config / broken-db /
broken-config / engine-locked — silently suppressing brain blocks for a
fully-working remote brain.
New evidence source: hasRemoteOnlyGbrainMcp() reads ~/.claude.json MCP
registrations (user scope AND project scope) with the same classification
rules as gstack-gbrain-detect's tier-3 fallback. File-read only — no
subprocess, no network (a classifier network probe is the #1964 pathology).
Wired at two sites in freshClassify:
- missing-config branch: a bearer thin client may never have run a local
init; if the host's only gbrain registration is remote-HTTP, that
registration IS the brain → thin-client.
- post-probe-failure demotion: broken-db / broken-config / engine-locked
reclassify to thin-client when the only gbrain registration is remote.
A local-stdio sibling registration blocks the demotion (federation
guard: a user running a local engine plus a remote team brain keeps
precise local statuses). "timeout" is excluded — already usable, and
may be a genuinely healthy slow local engine.
7 new unit tests in test/gbrain-local-status.test.ts: user-scope, project-
scope, engine-locked/broken-db demotion, federation guard, no-registration
discriminator, end-to-end --is-ok gate (35 pass total in the file).
Root-cause analysis by @d-danielsun in #2520.
Fixes #2520
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: resolve GBRAIN_HOME with gbrain's parent-dir semantics (#2521)
gstack treated GBRAIN_HOME as the config directory; gbrain's configDir()
treats it as the PARENT and always appends `.gbrain` itself (the contract
is explicit in gbrain's source: GBRAIN_HOME=/tmp/x → /tmp/x/.gbrain/
config.json). With GBRAIN_HOME set, gstack classified engine status from
a file gbrain never reads — the probe's two halves (file checks vs the
spawned `gbrain sources list`) looked at DIFFERENT installs, so any
resulting status was arbitrary: missing-config/broken-config against
healthy installs, or a thin-client marker gstack saw that gbrain itself
reported as "No brain configured".
New shared resolver `gbrainConfigDir()` in lib/gbrain-exec.ts is the
single source of truth. All seven gstack sites route through the contract:
- lib/gbrain-local-status.ts gbrainConfigPath (the classifier's file half)
- bin/gstack-gbrain-detect GBRAIN_CONFIG + readRemoteMcpUrl
- lib/gbrain-exec.ts buildGbrainEnv (the probe's DATABASE_URL seed —
fixing only the classifier would have left the split-brain in the
spawn half, flagged by the reporter)
- lib/gbrain-guards.ts gbrainHome (clones-dir + autopilot-lock paths)
- lib/gstack-memory-helpers.ts gbrainConfigPath (engine-tier fallback)
- bin/gstack-gbrain-install pre-doctor config check (shell)
Unit tests cover GBRAIN_HOME set (config found at $GBRAIN_HOME/.gbrain),
the old flat layout explicitly NOT read (both classifier and
buildGbrainEnv), and unset (~/.gbrain unchanged). Existing fixtures that
encoded the deviant flat layout are updated to gbrain's contract.
Root-cause analysis by @d-danielsun in #2521.
Deviation from the 3-site plan spec: the same deviant resolution existed
in four more sites (buildGbrainEnv, gbrain-guards, memory-helpers,
gbrain-install); fixing only three would have left gstack disagreeing
with itself as well as with gbrain, so the whole class moved to the
shared resolver in one change.
Fixes #2521
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: read project-scoped MCP registrations in gbrain detection (#2499)
Claude Code registers MCP servers at two scopes in ~/.claude.json: user
scope (.mcpServers) and project scope (.projects["/abs/path"].mcpServers
— what `claude mcp add` WITHOUT --scope user writes). Every gbrain
detection site read only user scope, so a correctly configured
project-scoped brain was invisible: brain-aware blocks suppressed,
remote-mode artifacts sync never recognised, and detectEndpointHash fell
through to the 'local' literal — two different project-scoped brains
hashed identically, so switching between them never invalidated the
cache, the exact scenario the function's docstring says it exists to
catch. Nothing errored; the features just quietly were not there.
Two sites fixed:
- scripts/resolvers/preamble/generate-brain-sync-block.ts: the shared
detection block (rendered into every tier-2+ SKILL.md) now resolves the
gbrain entry ONCE into _GBRAIN_MCP_ENTRY — user scope first, then the
nearest-ancestor project entry for $PWD that actually carries a gbrain
server (longest matching key with a path-boundary check: /a/repo never
matches /a/repo2; a nested project WITHOUT gbrain doesn't shadow its
parent's registration). _GBRAIN_MCP_TYPE and _GBRAIN_HOST extract from
the resolved entry, so claude.json is parsed once per skill start. All
SKILL.md files regenerated in this commit; the ship golden fixtures and
three carve-guard skeleton caps (plan-eng-review, plan-devex-review,
office-hours; ~1.5KB rendered growth per skill) are refreshed with
measured values.
- bin/gstack-brain-cache detectEndpointHash: same resolution order in TS
(user scope, else nearest-ancestor project entry by cwd, both path
separators for Windows keys).
Tests: rendered-output tests in test/gen-skill-docs.test.ts pin the
regenerated block (static markers + a FUNCTIONAL run of the exact
rendered lines against a fixture ~/.claude.json with only a
project-scoped registration, plus an outside-cwd discriminator);
detectEndpointHash unit tests in test/brain-cache-roundtrip.test.ts cover
project-scope resolve, path-boundary, nearest-ancestor distinct hashes,
and user-scope precedence.
Root-cause analysis by @samporter-31 in #2499.
Fixes #2499
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: /sync-gbrain respects an existing valid .gbrain-source pin (#2417)
/sync-gbrain always derived a new worktree-scoped source ID, even when
the repository already carried a valid .gbrain-source pin created through
the native GBrain source workflow — silently bypassing the selected
source boundary, registering a duplicate federated source, and routing
later dream/cycle checks to the wrong source.
Now a local pin is reused when it passes the fail-closed identity checks:
the ID is syntactically valid, the source is registered, and the
registered path realpath-resolves to the current checkout (so a stale or
copied dotfile can't redirect a sync into another repo's source). A
confirmed pin is treated as user-managed — synced and attached without
add/remove, legacy migration, or federation changes. Dry-run stays
spawn-free (reads only the local marker for previews). Missing, invalid,
stale, or unreadable pins fall back to the existing generated source ID.
Absorbs PR #2417 by @exGeni (applied via git am -3; 42 tests pass in
test/gstack-gbrain-sync.test.ts including the new pin-respecting
coverage: spawn-free dry-run, symlink-equivalent registered paths,
non-dry-run sync/attach with no add/remove, dream routing, unreadable
markers, config-backed env use).
Co-authored-by: Evgenii Lopatin <e75533@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: gstack-gbrain-install --dry-run no longer requires the network (#2540)
The GitHub reachability probe (curl --head, 10s max) was gated only on
--validate-only, so a --dry-run — which prints a plan and exits without
ever cloning — could fail with exit 3 "cannot reach https://github.com"
whenever the curl lost a race for sockets/DNS. Reproducible at ~15% by
running 60 dry-runs concurrently, and the cause of intermittent red in
the D5 detect-first tests, which call this exact path.
The probe now also skips under --dry-run: requiring the network for a
plan-print buys nothing and costs a real failure mode. Real installs
still fail fast when offline rather than hanging git clone.
Absorbs PR #2540 by @CarringtonCreative (applied via git am -3;
26 tests pass across test/gbrain-detect-install.test.ts +
test/egress-receipt-wiring.test.ts).
Fixes the offline/flake half of #2536.
Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: accept 3-digit semver + package.json version sources (#2501)
Two version-source shapes failed CLOSED in a way that silently disabled
/ship's queue-collision check:
1. A --version-path / .gstack/version-path target that is a package.json
was read as raw text: the whitespace strip turned the JSON into
'{"name":"frontend",... which parseVersion rejected, so every read —
local, `git show`, and rival PRs' claims through the GitHub/GitLab
Contents APIs — fell back to 0.0.0.0 and competing claims were dropped
as "malformed".
2. parseVersion required exactly four components, so gstack-next-version
exited 2 on EVERY invocation in a 3-digit repo. That CLI IS the
queue-collision check; /ship then took its documented offline path of
naive local arithmetic, two branches cut from the same base picked the
same version, and git merged the duplicate without a conflict.
New lib/version-source.ts holds the shared semantics so both CLIs agree
by construction: parseVersion accepts 3- or 4-digit (3 pads the micro
slot for uniform comparison), versionWidth/fmtVersion keep a 3-digit repo
3-digit through bumping and formatting, micro coerces to patch on 3-digit
repos (with a warning in the output), and extractVersion reads a .json
version-path as JSON (.version) from any byte source. gstack-version-bump
treats a package.json version-path as that repo's single source of truth
(written in place, DRIFT_* states can't arise — no second file to drift
from). Detection is by shape, not new configuration.
Scope per the wave plan's version-tooling end-state spec (decision 11,
ENG-OV1): this is the READING capability + 3-digit acceptance ONLY.
gstack's own VERSION file stays the 4-digit source of truth; nothing here
flips authority to package.json. The PR's bundled fix for the
.gstack/version-path pin being ignored by classify's base read lands
separately (#2462) — these tests drive the JSON version-path through the
explicit --version-path flag.
Re-derived from PR #2501 by @YiftahR (73 tests pass across
test/gstack-version-bump.test.ts, test/gstack-next-version.test.ts,
test/ship-version-sync.test.ts).
Fixes #2501
Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: write/repair sync npm lockfiles' version fields (#2567)
npm records the package version twice in its lockfiles — top-level
`version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
describing the root package itself) — and `npm install` keeps both in
step. gstack-version-bump write/repair updated VERSION + package.json but
left the lockfile behind, so every /ship bump in an npm repo drifted one
field per release until someone ran npm, dirtying the tree on the next
`npm install` far from the cause.
write and repair now mirror the version into package-lock.json AND
npm-shrinkwrap.json (which shares the format and, when present, is what
npm actually honors) as a pure JSON edit — no npm spawn, no
dependency-tree churn, dependency entries untouched. Per the wave plan's
version-tooling end-state spec (decision 11): synced ONLY when the file
already exists, never created (gstack itself is bun-only). A failed
manifest/lockfile write keeps the existing exit-3 half-write semantics so
classify reports DRIFT_STALE_PKG on re-run instead of hiding the drift.
Tests: 5 new cases in test/gstack-version-bump.test.ts — both lockfile
version fields synced with deps untouched, repair heals a stale lockfile,
lockfileVersion 1 (no packages map) doesn't crash, npm-shrinkwrap.json
synced without inventing a package-lock.json, malformed lockfile exits 3
loudly (26 pass total in the file).
Re-derived from PR #2568 by @ortonom under decision 11.
Fixes #2567
Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: subdirectory manifests + npm-valid version mirror (#2531)
Two gaps in gstack-version-bump's manifest handling, resolved to the wave
plan's version-tooling end-state spec (decision 11):
1. Subdirectory manifests. A repo whose only Node package lives in web/,
app/, or frontend/ has no ROOT package.json, so join(cwd,
"package.json") reported pkgExists:false and every bump silently wrote
VERSION alone — leaving the manifest to be bumped by hand, which is
exactly the drift this tool exists to prevent, in the one layout where
it silently did nothing. All three subcommands now resolve the
manifest as --package-json-path → .gstack/package-json-path →
./package.json (mirroring resolveVersionPath).
2. npm-valid mirror. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's
semver is 3-component and rejects a fourth, so mirroring the raw form
breaks `npm ci` in any repo npm actually manages. The manifest and its
lockfiles now carry the npm-valid 3-digit translation (1.67.0.0 →
1.67.0) via npmVersion() in lib/version-source.ts. VERSION stays the
4-digit source of truth. classify judges drift against the TRANSLATED
form — a correctly-synced `0.1.25` no longer reads as eternal drift
against `0.1.25.0` — and grandfathers the pre-v1.67 1:1 four-digit
mirror as in-sync (flagging it DRIFT_UNEXPECTED would hard-stop /ship
on every existing repo on upgrade day; the next write migrates the
manifest to the translated form). Lockfiles are synced beside the
resolved manifest — including beside a pinned JSON version-path — and
only when they already exist.
classify output gains pkgPath and expectedPkgVersion for observability;
write/repair report packageJsonPath + packageJsonVersion. The /ship Step
12 prose (ship/SKILL.md.tmpl) documents the resolution chain and the
translation; SKILL.md files regenerated and ship golden fixtures
refreshed in this commit.
Tests: subdirectory pin + --package-json-path override, translated-form
classify (FRESH/ALREADY_BUMPED, no false drift), grandfathered 1:1
mirror, genuine divergence still drifts, repair to the npm-valid form
(33 pass in test/gstack-version-bump.test.ts; 526 pass across the five
affected files including goldens and parity).
Re-derived from PR #2531 by @CarringtonCreative on top of the 3-digit/
JSON version-source work, under decision 11 (which resolves the PR's
lockfile-gated translation in favor of an unconditional npm-valid
mirror).
Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: git-based version allocator when the PR queue is unreachable (#2545)
When the host query (gh/glab) failed, gstack-next-version returned
offline:true with an EMPTY claim set, and /ship's documented fallback was
local BUMP_LEVEL arithmetic. Local arithmetic cannot see a sibling's
claim, so the fallback allocated a version another open PR already held —
observed in a downstream repo where two merged PRs both read v0.1.57.0
(and an audit found four such duplicate pairs over three weeks).
New fetchGitClaimed() degrades the QUEUE VIEW without degrading the
ALLOCATION: git already knows what the API was asked for. It reads every
remote-tracking branch's pinned version file (through extractVersion, so
JSON version-paths resolve on remote refs too and each branch's own digit
width is preserved) plus the versions already shipped in the base's last
400 commit subjects (3- or 4-digit; the cap announces itself in warnings
when it truncates). The fallback runs only when the host told us nothing
— the online path is untouched — and the output gains a load-bearing
`fallback: "git" | null` field that /ship can branch on, plus explicit
warnings for both the recovered-from-git and the nothing-found cases.
Tests: end-to-end stub-gh offline contract (fallback:'git' + a valid
version + the warning), sibling-claim discovery from remote-tracking
refs, the pick advancing past the sibling's claim, shipped-subject
scanning, JSON version-path claims on remote refs, and non-repo
degradation to a warning (45 pass in test/gstack-next-version.test.ts).
Re-derived from PR #2545 by @CarringtonCreative under the wave plan's
version-tooling end-state spec; the PR's own VERSION/CHANGELOG stamping
is stripped (release stamping happens at /ship time, not per commit).
Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: version-bump honors the .gstack/version-path pin in versionRel (#2462)
cmdClassify's current-version read already resolved the
.gstack/version-path pin, but versionRel — the repo-relative path fed to
`git show origin/<base>:<path>` — was derived from the CLI flag alone
(`argVal(args, "--version-path") ?? "VERSION"`). In a pinned repo with no
explicit flag, base and current therefore read DIFFERENT files: current
from the pinned file, base from the root VERSION. On a repo with no root
VERSION, the base always read 0.0.0.0 — and the pinned-JSON handling
never engaged, so a pinned package.json was read as raw text
(currentVersion 0.0.0.0) and `write` would have overwritten the manifest
with a bare version string.
New resolveVersionRel() resolves the pin's REPO-RELATIVE form once
(flag → .gstack/version-path first line → "VERSION"); classify, write,
and repair all derive both the relative and absolute paths from it, so
base and current reads can no longer diverge. The old resolveVersionPath
(which returned an absolute path `git show` cannot use) is folded in.
Unit tests (the ENG-OV6 spec case plus write/repair coverage): pin set +
no flag → classify reads base AND current from the SAME pinned file
(plain-text sub/VERSION and pinned frontend/package.json, both against a
real git base with NO root VERSION anywhere), write updates the pinned
manifest in place without inventing a root VERSION, repair treats the
pinned JSON as single-source, and the explicit flag still overrides the
pin (38 pass in test/gstack-version-bump.test.ts).
Re-spec'd per ENG-OV6 from the report in #2462 (the originally-filed
classify-read hypothesis was already handled; the live bug was the :138
versionRel derivation). Same fix shape independently identified in
PR #2501 by @YiftahR.
Fixes #2462
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: diff-scope glob coverage, honest exit contract, dirty-tree visibility (#2526, #2455, #2299)
Three silent-skip classes in bin/gstack-diff-scope, each of which quietly
disabled scope-gated reviewers in /ship and /review:
1. Pattern gaps (#2526, #2455). `*/api/*` required a path segment BEFORE
api/, so a root-level api/ layout (Vercel serverless, Next.js pages/api
at root) never set SCOPE_API — 63 serverless functions in the
reporter's payments repo, none ever classified, the API-contract
specialist silently skipped on every payment PR (it found a CRITICAL
when run by hand). Same for root-level migrations/. And the Rails
data_migrate gem's db/data/ data migrations — arbitrary Ruby run
unattended against production data — fell through to plain BACKEND, so
the [NEVER_GATE] data-migration specialist never got the chance to
run. Added: api/*, migrations/*, db/data/*, data_migrations/*.
2. All-false was indistinguishable from "could not look" (#2526). New
contract: empty change set → all false exit 0; >=1 match → flags
exit 0; changed files with ZERO matches → SCOPE_ERROR=unmatched + the
unmatched paths as comment lines + exit 2 (a new top-level layout now
trips loudly instead of invisibly disabling reviewers); unresolvable
base ref (shallow CI checkout) → SCOPE_ERROR=no_base + exit 2 instead
of a green that means "we could not look". Every output line stays a
shell-safe assignment or comment for sourcing consumers, which
tolerate the nonzero exit today (source ... || true / eval).
3. Uncommitted work was invisible (#2299). /ship detects scope in Step 9,
BEFORE it commits in Step 15, so the common start-work-then-ship flow
ran the classifier against an empty diff and skipped every reviewer.
The change set is now the UNION of committed diff + working tree +
untracked files. Also from #2299: the single first-match-wins case
made the nine flags mutually exclusive (Button.test.jsx set FRONTEND
but not TESTS; util.test.ts the opposite) — each category now gets its
own case, with BACKEND deliberately still excluding frontend
component/view files. And file listing is NUL-safe (git diff -z), so
non-ASCII paths no longer defeat extension globs via octal quoting.
Deliberate behavior change (flagged in #2299): with independent flags, a
backend test file sets BACKEND and TESTS, which can trip the security
specialist's SCOPE_BACKEND gate on test-only PRs — errs toward more
review, not less.
Table-driven tests cover every glob class (root api/, nested api/,
controllers, openapi, root/nested/prisma/db-migrate/db-data migrations,
dual-category test files, auth, prompts, docs, plain classes), the
four-state exit contract, dirty-tree + untracked visibility, and the
non-ASCII path case (39 pass in test/diff-scope.test.ts).
Fixes shaped by the reporters' patches: @grant-ship-it (#2526),
@mkyed (#2455), @ShahriarLak (#2299).
Fixes #2526
Fixes #2455
Fixes #2299
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact-prepush): don't re-scan commits a catch-up merge brought in
`remoteSha..localSha` is "everything new on this branch", which is not the
same as "everything new to the remote". Merge origin/main into a feature
branch and every commit main gained since that branch's last push becomes
an added line — content that is already published, already scanned, and
not this push's doing.
Two consequences, both observed:
· FALSE HIGH FINDINGS. A placeholder connection string in a fixture
someone else had already merged blocked an unrelated push as
db.url_with_password, telling the operator to rotate a credential
over a file they never touched. A guard that cries wolf on catch-up
merges is one people learn to bypass reflexively — which is exactly
how a real secret gets through.
· OVERSIZED SCANS. The SCAN_CHUNK_BYTES comment already records a
1,146,782-byte diff from "a feature branch catching up to a busy
main" blowing the engine's 1 MiB cap. Same root cause, treated there
as a size problem. Narrowing the range fixes the size too.
A two-dot range cannot express this: after merging main, neither the
remote tip nor the merge-base with main is an ancestor of the other, so
no single base excludes both.
The narrowed range is `rev-list localSha --not remoteSha --remotes`.
remoteSha STAYS the base — it is what git tells us the remote has, and is
authoritative in a way --remotes is not, since tracking refs can be
absent or stale. Using --remotes alone excludes nothing in a repo without
them, so every commit ever made reads as new. That is the same false
positive from the other direction, and it is what the existing test
"only NEW content is scanned (remote..local), not pre-existing" catches.
When excluding tracking refs changes nothing, this push has no catch-up
commits and the plain range already describes it exactly — so we defer to
it. That keeps every non-catch-up push on the original gitStrict diff
path, which is what #1946's fail-closed regression test exercises. A
narrowing that silently retired that test would be a worse trade than the
false positives it set out to fix.
Each commit is diffed alone. A merge's combined diff shows only content
present in no parent, so a secret introduced while resolving a conflict
is still caught while an ordinary merge contributes nothing.
Tests: 22/22 existing prepush tests still pass (two of them fail without
the remoteSha base and the defer-to-plain-range guard respectively —
verified by mutation). 5 new tests build real repositories on disk and
pin both directions: a catch-up merge no longer re-scans published
content, and secrets in new commits, in merge resolutions, and in
repos with no remote are all still scanned.
Absorbs PR #2592 by @Two-Six-Alpha-1115 (applied via git am -3; 5 new
tests pass in test/redact-prepush-scan-range.test.ts). Also narrows the
range for the rebased-force-push shape reported in #2573 — proven by the
follow-up regression test.
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact): parcel IDs are not phone numbers
A county tax-map parcel ID (APN) reads as a national-format phone number
to `pii.phone.e164` — the same collision class as the digit-only UUID
that `insideUuid` already guards. `12-3456789.000` matches, and so does
its normalized `123456789000`.
This is not a rare edge. Land, title and property-tax repos carry APNs
by the hundred; a single title branch pushed 2 MEDIUM findings, and the
same shape recurs in every fixture, mart and smoke in the domain. A
guardrail that cries wolf on the domain's primary identifier is one
people learn to wave through, which is how a real HIGH finding
eventually gets ignored.
The guard is deliberately narrow, in two tiers:
1. The DOTTED form is exempt on its own shape. No phone convention puts
a dot before a trailing 3-4 digit group after a 4-8 digit middle.
Hyphen-only variants (22-0001-000) are NOT shape-exempted — those
genuinely are phone-shaped.
2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the
exemption only by evidence: it must be the exact digit-normalization
of a punctuated APN within the surrounding window. Fixtures and marts
carry the pair; a real phone number has no such twin. This reads the
document's own evidence instead of guessing from digits.
Verified against the unmodified engine over inputs spanning every rule
family (AWS, PEM, GitHub PAT, email, IP, credit card, SSN, timestamp,
UUID, nine phone formats): exactly one behavior changed, the APN pair.
The new test pins both directions and was proven red under mutation —
stubbing the guard to `return true` (the dangerous blanket-exemption
failure) fails 12 of 15; `return false` fails 3.
Absorbs PR #2591 by @Two-Six-Alpha-1115 (applied via git am -3; 96 tests
pass across test/redact-parcel-id-false-positive.test.ts +
test/redact-engine.test.ts, and the pattern-lint / CLI / prepush-hook /
autoredact suites stay green).
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: prove the rebased force-push shape is scanned correctly (#2573)
#2573: after `git rebase origin/main`, the feature branch's remote tip
still exists locally (the pre-rebase tip) but is no longer an ancestor of
HEAD, so the old `remoteSha..localSha` range swept in every upstream
commit rebased onto — 1.14 MiB scanned instead of 0.27 MiB on the
reported repo, tripping the engine's 1 MiB cap and blocking the push
with engine.input_too_large (a HIGH that meant "the engine never ran",
not a finding).
The catch-up-merge narrowing (`rev-list localSha --not remoteSha
--remotes`) covers this shape too: the upstream commits are reachable
from origin/main's remote-tracking ref, which exists by construction —
you cannot have rebased onto origin/main without it. No residual gap
found; this lands the proof alone, end-to-end through the actual hook
binary with the real pre-push stdin protocol:
- fixture sanity: the pre-rebase tip exists locally, is NOT an ancestor,
and the OLD two-dot range would have swept in the upstream credential
- a clean rebased force-push passes — someone else's already-published
HIGH-shaped fixture no longer blocks it
- coverage is not narrowed: a HIGH in a rebased commit of our own still
blocks
- the scanned commit set is exactly the rebased own commits, so scan
size is proportional to OUR work, not to how busy main was
Analyzed non-gap, recorded in the test header: upstream commits in NO
remote-tracking ref cannot arise from the standard flow — rebasing onto
origin/<branch> requires the tracking ref, and rebasing onto a purely
local branch means the "upstream" content was never published, so
scanning it is correct.
Fixes #2573
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): ratchet four skeleton-size caps for the wave's preamble growth
The #2499 project-scoped-MCP jq entry-resolution adds ~340 bytes to every
brain-sync preamble block, and the wave's doc additions push four skills
3-91 bytes past their v1.64/v1.65 parity caps. Re-measured per the ratchet
protocol: plan-ceo-review 92,531 → cap 93,000; document-release 56,571 →
57,000; design-consultation 70,003 → 70,500; cso 75,891 → 76,400.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): file the v1.67 fix-wave deferrals + ZeroEntropy sunset deadline
The wave plan's "Cut from this wave" list becomes a durable next-wave queue:
Windows omnibus mining, AskUserQuestion numbering redesign, typecheck infra,
Chromium profile migration, triggers-frontmatter decision, release-tag
upgrade semantics, and the 15-PR feature triage queue. ZeroEntropy's Sept 4
2026 shutdown is filed P1 (calendar-driven — gbrain's default embedding
provider).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* deps(browse): bump playwright + playwright-core to 1.62.1 (P0 #2554 vehicle)
Split from dependabot #2582 per plan OV3: this commit bumps ONLY
playwright (^1.58.2 -> ^1.62.1, lock resolves playwright@1.62.1 +
playwright-core@1.62.1 exactly). puppeteer-core, @huggingface/transformers,
marked, and socks are deliberately NOT bumped here — they land separately
(73b) gated on the ONNX sidecar smoke.
Why: bun.lock pinned playwright(-core)@1.58.2, whose Chromium build
macOS XProtect now kills on launch — browse is dead on macOS (#2554).
1.62.1 ships Chromium 151.0.7922.34 (headless shell v1234), which
launches clean.
Verification: bunx playwright install chromium (Chrome Headless Shell
151.0.7922.34 downloaded), then the full browse suite from browse/:
2016 pass / 32 skip / 2 fail across 129 files (133.9s). Both fails are
playwright-independent: data-platform.test.ts "rejects paths in cwd"
expects <cwd>/package.json to exist (browse/ has none; passes from repo
root, the shard runner's cwd — 15/15), and stealth-webdriver.test.ts
passes standalone (15/15) — a 5s-timeout flake under full-suite parallel
load.
Fixes the vehicle half of #2554 (self-heal lands next commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): XProtect launch-kill self-heal — classify, quarantine-clear, bounded reinstall (P0 #2554)
macOS XProtect definition updates can start SIGKILLing the exact Chromium
revision the lockfile pins (xprotectd killed revision 1208's headless shell
at spawn; the failure surfaced as a generic launch timeout). New
browse/src/xprotect-heal.ts heals it, once per process:
- Classifier (F9): positive signatures sourced from the #2554 report +
Playwright's launch-error format (signal=SIGKILL process-exit lines, and
launch timeout WITH a <launched> marker), negative-checked FIRST against
missing executable, spawn EACCES/EPERM, Linux sandbox denials, and plain
exitCode=1 crashes. darwin-gated.
- Heal (F4 one-shot, in-memory flag): clears com.apple.quarantine via
`xattr -dr` on chromium* revision dirs in the Playwright cache ONLY —
never a GSTACK_CHROMIUM_PATH bundle (probePoisonedChromiumBundle's scope
contract, double-gated at the call sites via usesCustomExecutable).
- Reinstall (E1/ENG-OV3): `bunx playwright install --force chromium` run
FROM THE GSTACK INSTALL ROOT — the root whose
node_modules/playwright-core/browsers.json pins the SAME chromium
revision our embedded playwright-core expects (a cwd-resolved bunx would
fetch latest and heal to the wrong revision). Bounded at 120s with a
process-GROUP SIGKILL on timeout; on any heal failure the caller gets the
ORIGINAL launch error + manual `bunx playwright install chromium`
guidance — the CLI never hangs.
- Verification (F9): post-install asserts the REGISTRY-derived executable
path exists (the revision dir playwright-core 1.62.1 expects), not merely
install exit 0.
- Logging (F11): every action emits one structured stderr line
([browse:xprotect-heal] JSON).
All three launch sites in browser-manager.ts (headless launch, headed
launchPersistentContext, handoff relaunch) route through
launchWithXProtectHeal with one post-heal retry. setup's
ensure_playwright_browser failure path gains the same quarantine-clear
(_clear_playwright_quarantine, Darwin-only, Playwright cache scope) before
its Chromium reinstall.
Tests: browse/test/xprotect-heal.test.ts — 33 pass (classifier both
polarities, one-shot guard incl. failed-heal consumption, custom-executable
scope, registry-revision expectation vs playwright-core browsers.json,
install-root revision matching, quarantine-clear scope, wrapper retry +
guidance surfacing). browser-manager unit/custom-chromium: 36 pass.
bridge-chromium-e2e real-launch smoke: 3 pass. setup-windows-fallback
ln-invariant: 9 pass. bash -n setup: clean.
Fixes #2554.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): daemon owns signal policy — handleSIG*:false at launch sites + SIGHUP shutdown (#2220)
Playwright's default handleSIGINT/handleSIGTERM/handleSIGHUP handlers close
Chromium the moment the DAEMON process receives a signal — which fights the
deliberate headless SIGTERM-ignore in server.ts (Claude Code's Bash sandbox
fires SIGTERM when the parent shell exits between tool invocations; the
daemon survives it by design, but Playwright's handler killed its browser
out from under it). All three flags are now false at all three launch sites
(headless launch, headed launchPersistentContext, handoff relaunch).
ENG-OV4: the daemon had NO process-level SIGHUP handler (only SIGINT and
the mode-aware SIGTERM handler), so flipping handleSIGHUP:false alone would
remove the ONLY Chromium cleanup on hangup. server.ts now routes SIGHUP to
activeShutdown — the same shutdown path SIGINT uses (closes Chromium,
releases ports, removes the state file).
Static tripwire (browse/test/launch-signal-flags.test.ts, house
grep-style): every chromium.launch/launchPersistentContext site must carry
the three flags (site count pinned at 3 so a NEW launch site trips it),
server.ts must keep the SIGHUP→activeShutdown route, and the deliberate
headless SIGTERM-ignore must still exist (the reason handleSIGTERM:false is
safe — pinned in the test's header comment).
Tests: launch-signal-flags 3 pass; browser-manager-unit 28 pass;
bridge-chromium-e2e real-launch smoke 3 pass.
Fixes #2220.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): absorb #2414 residuals — EPERM-alive liveness + Windows-dead test tripwires (re-derived)
Re-derive of PR #2414 (SYKhayyat) onto current main. Most of the PR already
landed in earlier waves: the tick-derived RESPAWN_GUARD_WINDOW_MS, the
spawnTerminalAgent windowsHide flag, the process-liveness regression tests,
and the browse/test import.meta.path sweep are all on main. Two pieces
remained:
1. isProcessAlive EPERM semantics (error-handling.ts): on the signal-0 path,
EPERM means the process EXISTS but we lack rights to signal it — that is
ALIVE. Returning false made callers that validate liveness before killing
(killAgentByRecord, the terminal-agent watchdog) skip the kill and respawn
around a survivor — the self-reinforcing one-leak-per-tick chain from
#2414/#2295. Matters for cross-user PID checks.
2. Six test/ files ADDED SINCE the PR reintroduced the exact Windows bug its
second commit fixed: `new URL(import.meta.url).pathname` yields
`/C:/Users/...` on Windows, so path.resolve prepends the cwd drive and
every tripwire ENOENTs instead of asserting anything (egress-receipt,
egress-lib, egress-receipt-wiring, gstack-egress-cli,
pty-skill-seeding-wiring, skill-census). All six now use
import.meta.path — Bun's absolute native path, identical arity.
The remaining #2414 piece — replacing the Windows tasklist probe with
signal-0 — lands as its own commit (#1952) on top of this shape.
Tests: the 6 touched test files 47 pass; process-liveness-windows +
error-handling 13 pass.
Re-derived from PR #2414 by @SYKhayyat. Fixes the residual of #2295.
Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): isProcessAlive uses signal-0 on every platform — no more tasklist probe (#1952)
Replace the Windows tasklist shell-out in isProcessAlive with
process.kill(pid, 0), unifying all platforms on the POSIX idiom. Node maps
signal-0 to an OpenProcess existence check on Windows — and the Windows
daemon runs under Node (dist/server-node.mjs + bun-polyfill, the documented
oven-sh/bun#4253 fallback) — so the probe is portable.
Why the shell-out had to go, beyond the cosmetic conhost flash the watchdog
blinked into the foreground every 60s (#1952): a Bun.spawnSync that hits
its timeout still RETURNS with partial stdout, so the `.includes()` PID
match answered "dead" for LIVE processes under load — the false-negative
half of the #2414/#2295 leak chain. Signal 0 spawns nothing, cannot time
out, and is ~5 orders of magnitude faster (measurements in #2414). EPERM
still reports alive (process exists, we just can't signal it).
Layered on the post-#2414-absorb shape: test 3 in
process-liveness-windows.test.ts now asserts the probe is subprocess-free
on ANY platform (win32 exemption dropped), test 4's static tripwire loses
its error-handling.ts exemption (a `tasklist … PID eq` existence probe
anywhere in src/ now fails CI), and windows-spawn-hide.test.ts drops its
tasklist-in-error-handling needle (nothing spawns, which is stronger than
hiding the window).
Tests: process-liveness-windows + windows-spawn-hide + error-handling —
17 pass, 0 fail.
Fixes #1952.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): windowsHide sweep — flag every residual child_process site + full-census tripwire (#2160, #2415)
Add windowsHide:true at every remaining direct child_process call in
browse/src that could flash a console window on Windows:
- project-slug.ts (execSync gstack-slug)
- browser-skills.ts (cp.spawnSync git rev-parse)
- security-sidecar-client.ts (spawn — the LONG-LIVED Node sidecar, whose
missing flag parked a console window on the taskbar for the daemon's
whole lifetime)
- find-security-sidecar.ts (execFileSync node --version)
- meta-commands.ts (execSync git rev-parse in inbox + the osascript
activate call)
- browse-client.ts (cp.spawnSync git rev-parse)
- file-permissions.ts (execFileSync whoami.exe — Windows-only, ran bare)
- cli.ts (nodeSpawn osascript)
windows-spawn-hide.test.ts gains a SWEEP test on top of the existing
needles: it censuses EVERY child_process binding in src/ (static imports
incl. aliases, `await import()` / require destructures, and `import * as
cp` namespaces — 15 call sites across 10 files today) and fails CI on any
call without windowsHide within its options window. Exemptions carry
reasons — the one today is domain-skill-commands' interactive $EDITOR
spawn (stdio:'inherit'; CREATE_NO_WINDOW would detach a console editor
into an invisible console).
Tests: windows-spawn-hide 5 pass; file-permissions 19 pass; browse-client
28 pass; browser-skill-commands 29 pass (81/81 combined).
Fixes the app-side half of #2160; closes out #2415's residuals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): fail-fast busy-daemon semantics — never auto-kill an alive pid, add --force-restart (#2219)
The CLI killed live-but-busy daemons: a heavy dev-mode page (cold-compiling
Next.js route, timed-out navigation still churning) kept the daemon from
answering /health longer than the old ~1s probe window (3 × 250ms), so the
connection-error path declared it dead, SIGTERMed a healthy process, and
every kill lost the session's tabs, cookies, and logins (reproduced 4/4 in
the #2219 report).
New contract (decision 9 / F10):
- probeHealthWithBackoff is budget-based: ~8s total
(HEALTH_PROBE_TOTAL_BUDGET_MS), 500ms intervals, each probe self-bounded
at 2s — sized to the observed busy windows.
- decideDaemonRestart (pure, exported, unit-tested) encodes the IRON RULE:
healthy-after-probe → retry the SAME daemon; alive+unhealthy →
"daemon busy — retry or --force-restart" + NONZERO exit, daemon untouched;
only a DEAD pid (or an explicit --force-restart) reaches kill+restart.
- --force-restart global flag (extractGlobalFlags): the one consent path
that replaces a live daemon, always announcing the state it costs.
- Wired at all three kill sites: sendCommand's connection-error branch,
ensureServer's stale-state path (which previously killServer'd any alive
pid whose single 2s health probe missed), and connect — which used to
"Kill ANY existing server" and now refuses to replace a healthy daemon
without 'browse disconnect' or --force-restart. pair-agent's internal
headed switch passes --force-restart explicitly (the mode switch is that
command's stated purpose), preserving its behavior.
E5 IRON RULE regression tests (busy-daemon-iron-rule.test.ts, real spawned
CLI + fake daemons + live sleep-pid stand-ins per the
busy-daemon-recovery.test.ts pattern): healthy daemon SURVIVES connect
(refused with guidance, pid alive, state file untouched); wedged-alive
daemon + plain command → busy report, nonzero exit, pid alive; wedged
daemon + --force-restart IS killed and a real replacement daemon serves the
command. Plus pure-function coverage of all four decision outcomes and the
~8s budget pin.
Tests: busy-daemon-iron-rule 8 pass (16.7s, includes a real daemon
lifecycle); busy-daemon-recovery + proxy-config + daemon-mismatch-refuse +
cli-lock + cli-start-final-healthcheck + cli-setsid-daemonize 39 pass.
Fixes #2219.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): `stop` on a dead daemon is success — never boots a daemon to stop it (#2254)
Two changes, one contract:
- Pre-server short-circuit: `browse stop` is handled BEFORE ensureServer().
No daemon state → "nothing to stop", exit 0. Stale state (dead pid AND
dead port) → clean the state file, exit 0. The old flow routed stop
through ensureServer(), which started a fresh daemon + Chromium
(multi-second boot, resource churn) purely so it could be told to shut
down — or crashed on the stale state.
- Reconnect branch: a connection error while sending `stop` where the pid
turns out dead (daemon died mid-flight, between the short-circuit check
and the send) is treated as SUCCESS — the desired end state (no daemon)
already holds — instead of the crash-restart path.
Integration tests (stop-dead-daemon.test.ts, real spawned CLI + scratch
BROWSE_STATE_FILE): stop with no state exits 0 and spawns nothing (a
spawned daemon would have written the state file); stop with a stale state
file (dead pid + verified-closed port) exits 0, cleans the state, and
spawns nothing.
Tests: stop-dead-daemon 2 pass; busy-daemon-iron-rule 8 pass;
busy-daemon-recovery 1 pass (11/11 combined).
Fixes #2254.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(upgrade): /gstack-upgrade stops a stale daemon — deferring to a busy one (#2551)
A browse daemon started before an upgrade keeps serving the OLD binary's
code after `git reset --hard` + `./setup` — the running process holds the
old executable, so users on the "new" version kept getting pre-upgrade
behavior (and config-mismatch refusals against the new CLI) until they
happened to stop it by hand.
New unconditional Step 4.8 in gstack-upgrade/SKILL.md.tmpl (+ regen, same
commit): compare the running daemon's recorded binaryVersion (the
readVersionHash git-SHA the server stamps into its state file) against the
freshly built browse/dist/.version.
- Stale + responsive → `browse stop` (graceful), telling the user
old→new hash; the next command boots a daemon on the new binary.
- Stale + BUSY → DEFER (decision 10): never kill a busy daemon during
upgrade. Print the old→new hash and the escape hatch —
`browse stop` when it finishes, or `browse --force-restart stop` now.
- Dead pid / matching hash / no state → silent no-op.
Tests: skill-validation + gen-skill-docs 731 pass after regen.
Fixes #2551.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): terminal-agent allocates from the fixed port scan range, not port:0 (#2314)
The terminal-agent bound `Bun.serve({ port: 0 })` and kept that OS-assigned
port for its whole (weeks-long) lifetime. `port: 0` draws from the OS
EPHEMERAL range (49152-65535 on macOS) — the exact pool every short-lived
`app.listen(0)` test server draws from — so the agent squatted ports that
test suites expected to receive and silently absorbed their traffic as
phantom 404s (two squatting daemons verified in the report).
Fix per decision 8: extract the main server's port allocation into
browse/src/port-allocator.ts (checkPortAvailable / isPortAvailable /
findAvailablePort + the 10000-60000 range constants and the actionable
sandbox-vs-occupied error formatters, all verbatim from server.ts) and make
BOTH long-lived listeners use it — server.ts's findPort is now a thin
findAvailablePort(BROWSE_PORT) wrapper, and terminal-agent's buildServer
takes a pre-allocated port from the same range. No terminal-port consumer
carries a range assumption (they read the port file), verified by grep.
Tests: terminal-agent-port-range (new — allocator stays inside
10000-60000 and below the 49152 ephemeral floor, explicit-port honored,
occupied-explicit throws, static tripwires pin no-port:0 in
terminal-agent.ts and the shared wrapper in server.ts) + findport +
terminal-agent-integration/session-routing/detach-reattach +
dual-listener: 67 pass, 0 fail.
Fixes #2314.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): capture daemon stdout/stderr to browse-daemon.log + Windows polyfill spawn fixes (re-derived from #2461)
The detached daemon's stdout/stderr were wired to 'ignore' on every
platform, so every console.error('[browse] FATAL: ...') from a Chromium
crash, uncaughtException, or unhandledRejection was discarded at the OS
level — a crash-and-respawn looked identical to every other dropped
session, with nothing on disk recording why. Both spawn paths now redirect
to <stateDir>/browse-daemon.log (append mode, accumulates across respawns):
the Unix path via an fd from openDaemonLogSink(), the Windows path by
opening the fd INSIDE the node -e launcher string (an fd opened in cli.ts
would not cross the spawn boundary). Unwritable state dir falls back to
'ignore' rather than failing the launch.
Capturing daemon output is what surfaced the PR's second fix, still valid
on current main: bun-polyfill.cjs's Bun.spawn/spawnSync called Node's
child_process with a bare command name, which Windows can't resolve without
PATHEXT lookup ("spawn bun ENOENT" from the terminal-agent respawn path).
Routed through cross-spawn on win32 (now a direct dependency; already in
the tree transitively via @modelcontextprotocol/sdk) — the PR verified
empirically that shell:true does NOT neutralize cmd.exe metacharacters
reachable via `$B skill run` arg passthrough, and that Node refuses .cmd
spawns without a shell (CVE-2024-27980), so cross-spawn's combined PATHEXT
resolution + argument escaping is the only correct shape. The PR's third
fix (resolveDisconnectCause throwing "browser?.process is not a function")
already landed on main via the #2085 typeof guard — not re-applied.
F6 log hygiene (daemon-log-hygiene.test.ts): needle tests pin the log
wiring on both spawn paths (and that stdio 'ignore','ignore','ignore'
never returns), that bun-polyfill stays on cross-spawn with no shell:true,
that NO console.* call in src/ passes a token value (interpolated or bare
arg), and that the page-content carrier modules (tab-session, buffers,
content-security, activity) stay console-free — so neither AUTH_TOKEN nor
unsanitized page-derived strings can reach browse-daemon.log.
Tests: daemon-log-hygiene + bun-polyfill + windows-spawn-hide +
cli-setsid-daemonize 21 pass; stop-dead-daemon + busy-daemon-iron-rule
(exercises a REAL daemon boot through the new log-fd wiring) 10 pass.
Re-derived from PR #2461 by @phuttimatebenchanakatkul.
Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: raise gbrain version-probe timeout to 10s on Windows
On Windows the gbrain CLI is a .cmd shim that runs `bun run cli.ts`.
A cold spawn takes over the 2s timeout in resolveGbrainBin (warm runs
are ~700ms), so the probe times out, localEngineStatus classifies the
engine as "no-cli", and the 60s status cache then serves that false
negative to every skill preamble and sync run. /sync-gbrain skips the
memory stage with "gbrain CLI not on PATH" even though the CLI works.
Give the shim 10s of headroom, gated on NEEDS_SHELL_ON_WINDOWS so
POSIX keeps the cheap 2s probe. Applies to both resolveGbrainBin and
readGbrainVersion.
Observed on Windows 11, bun 1.3.14, gbrain 0.42.59.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): remove the dead security shield + unfed /health.security (re-derived from #2557)
The sidebar's SEC shield has been dead UI since the PTY terminal rewrite:
nothing set its data-status, nothing unhid it, and the /health.security
field behind it read getStatus() off ~/.gstack/security/session-state.json
— a file whose ONLY writer (sidebar-agent.ts) was deleted with the chat
path. /health therefore reported a permanent 'inactive', or a stale
FALSE-GREEN 'protected' wherever an old state file survived on disk (a
single unit-test run was enough to plant one). A green shield sourced from
leftover state reads as "no threats detected" when the real state is "not
measured" — the same fail-open class as #2026.
Removed (dead surfaces only): the shield markup/CSS and the stale
sidepanel.js comment; the /health security field and server.ts's getStatus
import; getStatus / SecurityStatus / StatusDetail / SessionState /
read+writeSessionState (and security.ts's dead child_process import); the
session-state + getStatus unit tests — including the round-trip test that
wrote real fixture data into ~/.gstack and left /health green forever.
(The PR's security-sidepanel-dom.test.ts deletion already happened on main
via #2230; its resolveDisconnectCause guard landed via the #2085 typeof
fix. Neither re-applied.)
Kept, per ENG-OV9 — security.ts has LIVE consumers: the pure combiner
(combineVerdict + THRESHOLDS), canary utilities, and extractDomain stay;
server.ts's /pty-inject-scan L4 path (isSidecarAvailable + scanWithSidecar)
is untouched. browse/test/server-security-surface.test.ts pins BOTH
directions: the dead surface stays dead (no /health security field, no
getStatus import, no reader of the security session-state file, shield
markup gone) and the live half stays live (sidecar wiring in server.ts,
combiner/canary exports in security.ts, /health carries no token — the
v1.63 regression wall). A future re-feed from LIVE signals must update
that test deliberately rather than resurrect the state-file path.
F13 (same commit): CLAUDE.md's Sidebar security stack section, ARCHITECTURE.md's
prompt-injection Visibility + critical-constraint paragraphs, and
BROWSER.md's security section now describe the removed surfaces as history,
not live features.
Net -166 lines. Tests: server-security-surface + security +
security-adversarial(+fixes) + security-integration + server-auth 114 pass;
sidepanel-* + extension-token + extension-sender-auth 58 pass / 2 skip.
Re-derived from PR #2557 by @frederik-kaster-noygear.
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): capture browser-skill subprocess output via temp files, not pipes (core of #2559)
Under a loaded parent, the FIRST piped Bun.spawn in a process
intermittently yields an empty stderr even though the child wrote it and
exited 0 — measured identically with readers-attached-before-exit and with
a manual getReader() drain, so it's loss inside the async pipe plumbing,
not read ordering. It flaked `$B skill test` (bun test writes its banner to
stdout and the pass/fail summary to stderr, so a dropped stderr silently
degraded the result to just the banner) and would blank a skill's JSON
result on `$B skill run` while still reporting success.
New runToFiles() points the child's stdout/stderr at temp files via
Bun.file() (never raw fds — closing self-opened fds around a spawn tripped
Bun's fd bookkeeping into a stray epoll_ctl EBADF), awaits exit, then reads
the files: the kernel has flushed everything by child exit, so the
post-exit read is complete, and chatty children can't stall on a full pipe
buffer. Both handleTest and spawnSkill route through it (timeout + capped
read preserved via timeoutMs/maxStdoutBytes). Bun.spawnSync would also
capture reliably but would deadlock: a spawned skill calls back into this
same daemon on GSTACK_PORT.
The `tests passed for "<name>"` fallback is gone — a passing bun test
always prints a summary, so exit 0 with no output means the run was NOT
captured, and handleTest now throws instead of fabricating success. The
E2E assertion checks both stream halves (banner + summary + "Ran N tests")
instead of the loose alternation whose `tests passed` branch matched the
synthetic fallback vacuously. A static tripwire pins the structure:
runToFiles owns the module's ONLY Bun.spawn, and no site reads child
output via stdout:'pipe' / new Response(proc.stdout) / getReader().
Scope: the PR's repo-wide test-file sweep is deliberately not absorbed —
this is the core only, per the wave plan.
Tests: browser-skill-commands + browser-skills-e2e + browser-skill-write
74 pass, 0 fail.
Re-derived from PR #2559 by @frederik-kaster-noygear.
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(browse): allow Emulation.setEmulatedMedia on the CDP allowlist (re-derived from #2419)
Adds Emulation.setEmulatedMedia to the deny-default CDP allowlist:
tab-scoped, trusted output (returns an empty result — no page content).
Unlocks media type/feature overrides (prefers-color-scheme,
prefers-reduced-motion, prefers-contrast, forced-colors) via `$B cdp`, so
dark-mode and a11y CSS branches are testable without a headed toggle. Like
setUserAgentOverride, the override persists on the tab until cleared with
an empty features array — noted in the entry's justification.
Registry test pins the entry (allowed + tab scope + trusted output); the
PR's VERSION/CHANGELOG stamping is stripped per wave convention (versioning
happens at /ship).
Tests: cdp-allowlist 7 pass, 0 fail.
Re-derived from PR #2419 by @meshailabs.
Co-authored-by: meshailabs <devsupport@meshai.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): create node bundle output directory
* fix(deps): bun-patch playwright-core 1.62.1 — windowsHide at launch + taskkill (#2160, #1989)
The repo's first patchedDependencies entry. playwright-core's bundled
process launcher (lib/coreBundle.js in the 1.62.x layout) spawns browser
children without windowsHide — Node defaults it to FALSE for
child_process.spawn — so Chromium children could flash a console window on
Windows, and its force-kill path shells `taskkill /pid <pid> /T /F`
through cmd.exe with the same omission. Both sites now pass
windowsHide: true via patches/playwright-core@1.62.1.patch (generated with
`bun patch` / `bun patch --commit`).
Coherence verified end-to-end: rm -rf node_modules && bun install applies
the patch cleanly (both sites present in the reinstalled tree), and a real
chromium.launch() through the patched bundle works.
browse/test/playwright-core-patch.test.ts pins the three-legged invariant
statically — package.json's patchedDependencies key is VERSION-KEYED
against the installed playwright-core, the patch file exists and carries
both sites, bun.lock records the patch, and the installed bundle actually
has it applied — so a future playwright bump that forgets to re-target the
patch fails CI with the exact key to regenerate (revert pairing: dropping
the c25 bump requires dropping this patch too).
Tests: playwright-core-patch 4 pass, 0 fail.
Fixes #2160, #1989.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(preamble): probe AGENTS.md for skill routing; team-init resolves GSTACK_ROOT (#2500)
The HAS_ROUTING preamble probe only checked CLAUDE.md, so repos that route
skills via AGENTS.md (the cross-harness convention for Codex, Cursor, and
generic agent hosts) reported HAS_ROUTING: no and got nagged to create
CLAUDE.md. The probe now iterates CLAUDE.md and AGENTS.md.
gstack-team-init's required-mode enforcement (the CLAUDE.md verification
snippet and the generated .claude/hooks/check-gstack.sh) hardcoded
~/.claude/skills/gstack, false-blocking installs living at any other host's
global root or the migrated ~/.gstack/repos/gstack location. Both sites now
resolve the install root: GSTACK_ROOT env first, then every registered
host's globalRoot, then the migrated repo path. Install instructions keep
pointing at the canonical Claude location.
test/routing-probe.test.ts pins both: rendered-preamble assertions plus a
live execution of the extracted probe block (AGENTS.md-only repo => yes),
and a drift test that requires every hosts-registry globalRoot to appear in
team-init's probe list.
Re-derived from PR #2500 onto current code (the PR's 52-file regen was
discarded and regenerated here). Contributed by @gamerey43.
Fixes #2500
Co-authored-by: gamerey43 <gamerey43@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(resolvers): empty find must not fall through to cwd (#2483)
find ... | xargs ls -t runs ls with NO operands when find matches nothing —
GNU xargs still invokes the command once, and ls -t with no operands lists
the current directory. Three sites misfired on fresh installs (no ceo-plans /
checkpoints / plans yet), exactly where a wrong answer is least likely to be
recognized: review.ts's plan fallback silently adopted a random cwd .md as
"the plan", and Context Recovery listed unrelated cwd files as RECENT
ARTIFACTS / LATEST_CHECKPOINT.
All three now use xargs -r ls -t, mirroring the shape the sibling
bin/gstack-codex-session-import fix (#2482) landed with: -r pins the BSD
skip-on-empty behavior on GNU too, and BSD xargs accepts -r as a no-op.
test/empty-find-fallthrough.test.ts pins it four ways: no bare xargs ls -t
in scripts/ or bin/, both rendered Context Recovery sites guarded, a live
execution proving an empty checkpoints dir yields no checkpoint (not a decoy
cwd file), and a rendered-SKILL.md sweep.
Re-derived from PR #2483 onto current code. Contributed by @tranthanhnhatkhoa.
Fixes #2483
Co-authored-by: tranthanhnhatkhoa <tranthanhnhatkhoa@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex): retire deprecated web-search flag behind one CODEX_WEB_SEARCH_FLAG constant (#2525)
codex >=0.144 deprecates the legacy --enable-based web_search_cached
spelling (web search is on by default; --enable <FEATURE> now means
-c features.<name>=true, verified against codex 0.147.0's exec --help).
Every gstack codex invocation now passes -c 'web_search="cached"' instead.
The flag previously lived inline at 19 raw sites. Per ENG-OV11a the 10
template-inline sites (autoplan/SKILL.md.tmpl x4, codex/SKILL.md.tmpl x6)
convert to a shared {{CODEX_WEB_SEARCH_FLAG}} token first, so ONE resolver
constant (CODEX_WEB_SEARCH_FLAG in scripts/resolvers/constants.ts) now
covers all sites: review.ts x5, design.ts x3, the token resolver in
utility.ts, and the tool-map helper comment.
codex/SKILL.md.tmpl's web-search prose guarantee is corrected: the -c form
explicitly overrides a top-level web_search config (the legacy flag yielded
to it), and native codex review disables web search regardless of
configuration, so the flag is a no-op on the default Review path.
test/codex-web-search-flag.test.ts is the safety net: repo-wide grep
tripwires assert NO rendered SKILL.md/section/golden and NO source file
carries the deprecated spelling, and that the token resolves in rendered
output.
Fixes #2525
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(question-tuning): interpolate the absolute question-registry path (#2489)
The Question Tuning preamble pointed agents at a RELATIVE
scripts/question-registry.ts in the same sentence whose ${bin} path renders
absolute. Agents run with cwd in the USER'S project — the relative lookup
never resolves, silently fails, and the documented {skill}-{slug} fallback
fabricates a singleton question_id every time (one observed
/plan-eng-review session: 21/21 unregistered ids, so no per-question
preference can ever attach).
The resolver now interpolates ctx.paths.skillRoot the way sibling resolvers
interpolate bin paths: ~/.claude/skills/gstack/scripts/question-registry.ts
on Claude, $GSTACK_ROOT/scripts/question-registry.ts on env-var hosts.
test/question-tuning-registry-path.test.ts asserts the rendered path per
host, forbids the bare relative shape, and checks the target file exists in
the install tree.
Fixes #2489
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(resolvers): slug-canonical branch form in file-path positions (#2550, #1851)
Branch-name-to-filename had incompatible rules across writer and readers:
gstack-review-log WRITES <branch>-reviews.jsonl with the gstack-slug
canonical form (tr '/' '-' then tr -cd 'a-zA-Z0-9._-', bin/gstack-slug:178),
but Context Recovery PROBED it with raw $_BRANCH from git branch
--show-current — so for any branch containing a '/' the REVIEWS line never
fired (#1851's reader half of #1127). The probe now uses ${BRANCH:-unknown},
the canonical value the gstack-slug eval on the block's first line already
sets. review.ts's plan content-search BRANCH gains the missing tr -cd half
so it matches the same canonical pipeline.
Full audit of the 5 raw $_BRANCH interpolation sites in scripts/resolvers/
(E3): generate-context-recovery.ts:16 (reviews.jsonl path) -> canonical
BRANCH; :19/:21 (timeline.jsonl content greps) KEEP raw $_BRANCH because the
timeline writer (preamble's gstack-timeline-log call) stores the raw branch
in the "branch" field — slugging the reader would break that pairing;
generate-preamble-bash.ts:29 (display echo) and :97 (timeline data write)
keep raw by design. The *-$BRANCH-design-*.md family (review.ts:313 + 3
plan-review templates) is a consistent tr '/' '-' writer/reader pair and is
deliberately untouched.
test/branch-slug-hygiene.test.ts pins the discipline: a rendered-output
sweep forbids raw $_BRANCH adjacent to a path separator or as a filename
prefix in ANY generated SKILL.md/section, and a live round-trip on a
feat/slash branch proves gstack-review-log's write is found by the rendered
probe (with the raw-form shape as a negative control).
Reader-side fix folded from PR #1851. Contributed by @harjothkhara.
Fixes #2550
Fixes #1127
Co-authored-by: harjothkhara <harjothkhara@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ship): review fix loop stays in one invocation, bounded at 3 cycles (#2391)
The pre-landing review committed its fixes, then STOPPED and told the user
to run /ship again — 5-10 manual invocations on a branch with a few
auto-fixable findings, violating /ship's fully-automated contract. There is
no user decision between those invocations; each rerun just repeats the
workflow until a review pass produces no fixes.
ship/sections/review-army.md.tmpl item 7 now makes the loop explicit: after
committing fixes, re-run the test suite (Step 5) and this review (Step 9
items 2-6) in the SAME invocation, repeating until one full pass applies
zero fixes, then continue to Step 12. Bounded at 3 fix cycles — a review
that will not converge STOPs with a report of which findings keep
reappearing (a genuine blocker), never with a rerun request.
test/ship-review-loop.test.ts asserts no rendered ship surface (section +
all three host goldens) carries the STOP-and-rerun shape and that the
bounded loop language renders.
Fixes #2391
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(codex): model round-trip probe — an unusable configured model fails fast with guidance (#2477)
The auth probe accepts 'auth exists' as readiness, but a ChatGPT account
with a stale model pin in ~/.codex/config.toml passes it and then EVERY
mode dies with an HTTP 400 ('The <model> model is not supported when using
Codex with a ChatGPT account') and no pointer to where the model came from
— one report burned ~40 minutes and four invocations plus a strings dump
of the binary before finding the one-line config fix.
bin/gstack-codex-probe gains _gstack_codex_model_probe: a short
codex exec 'reply OK' round trip with the configured model, gated behind
the cheap auth probe at all three preflight sites (codex Step 0.5, the
shared codexPreflight in scripts/resolvers/constants.ts — which grows a
model_unusable CODEX_MODE branch — and autoplan's availability chain).
Verdicts: MODEL_OK (cached 1h, keyed on config.toml + auth.json mtimes so
a pin edit or re-login re-probes immediately), MODEL_UNUSABLE (exit 1,
prints the rejection plus HINTs at the model= pin and the
[notice.model_migrations] table), MODEL_PROBE_INCONCLUSIVE (timeout or
transient: FAIL-OPEN so network luck never wedges codex mode).
The 'Model not supported (HTTP 400)' Error Handling entry already shipped
in v1.64.0.0; Step 0.5's prose now routes MODEL_UNUSABLE to it.
test/codex-model-probe.test.ts drives all four behaviors against a stubbed
codex binary (invocation-counted cache hit, hint content, fail-open
polarity, mtime invalidation).
Fixes #2477
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): skip nested codex spawns when already running under a Codex host (#2519)
/review executed inside a Codex host spawned the codex specialist passes
anyway — the same model reviewing itself, at multiplied cost (observed:
15M tokens for a single /review).
Detection per maintainer decision 7: a presence probe of the Codex session
env. A live Codex session exports CODEX_THREAD_ID and CODEX_SANDBOX into
every shell it spawns — verified during implementation against a live
`codex exec 'env | grep -i codex'` capture on codex 0.147.0
(CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt, CODEX_SANDBOX_NETWORK_DISABLED=1,
CODEX_CI=1). The shared codexPreflight in scripts/resolvers/constants.ts
(consumed by all three review.ts army blocks: adversarial, codex plan
review, codex doc review) now yields CODEX_MODE=under_codex and instructs
exactly one printed notice — '[running under Codex — nested codex passes
skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]'. The override env var
forces the nested passes for users who really want them. codex/SKILL.md.tmpl
Step 0.5 gains the same probe: /codex under a Codex host stops with a
one-line notice, since its whole value is a SECOND model's opinion.
test/codex-under-codex-detection.test.ts runs the rendered preflight bash
under all four env combinations (thread-id only, sandbox only, forced,
clean) and asserts the probe + notice render in the three preflight
consumers and the codex skill.
Fixes #2519
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(build): convert MSYS paths for Bun in the Windows server-bundle build (#2452)
browse/scripts/build-node-server.sh resolves GSTACK_DIR with pwd, which
under MSYS/Git Bash yields a /c/... style absolute path that Bun cannot
open ('FileNotFound opening root directory') — the Windows Node-server
bundle build died at the first bun build. Convert via cygpath -m on
MINGW/MSYS/CYGWIN before deriving SRC_DIR/DIST_DIR.
Re-derived from PR #2452, taking only the cygpath build half — the PR's
icacls principal-ambiguity half already landed on main
(browse/src/file-permissions.ts's SID-form principal). Verified the build
bug still exists on current code before absorbing (build-node-server.sh:10
had no conversion). Contributed by @chiragborse1.
Co-authored-by: chiragborse1 <chiragborse1@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): update four main-side gen-skill-docs assertions to the T6 contracts
Three contracts moved under this theme and the assertions pinned the old
shapes:
- The routing-probe assertion expected the single-file
'grep ... CLAUDE.md' shape; #2500 made the probe iterate CLAUDE.md AND
AGENTS.md, so it now asserts the for-loop + quoted $_RF shape.
- The three Claude-output Codex-path bans tripped on ~/.codex/config.toml,
which the shared codexPreflight's model_unusable branch (#2477) now
documents in rendered output. That path is the Codex CLI's own config
file — the same user-facing class as the already-exempt
~/.codex/sessions/ — so it is scrubbed before the host-path ban, with the
reasoning recorded next to the existing exemptions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync-gbrain): dream pack-capability WARN anchors to the graph phase
Fixes #2341. classifyDreamOutcome matched the bare phrase "does not declare
this phase", but gbrain's only emitters are the CONTENT phases
(extract_atoms, synthesize_concepts) — which the default base packs
legitimately skip while resolve_symbol_edges still runs. Every base-pack
brain therefore got the pack-capability WARN with its wrong, costly
remediation ("switch schema packs"), masking real graph problems. The match
now anchors to the graph phase (resolve_symbol_edges/extract_code_symbols);
a base-pack run with a built graph is clean, and a resolved-0 run gets the
honest 0-edge diagnosis.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): install office-hours into the external-host runtime roots
Fixes #2449. plan-eng-review's inline office-hours step reads
$GSTACK_ROOT/office-hours/SKILL.md, but the codex/factory/opencode runtime
roots never installed it — the documented path pointed at nothing on every
external-host install (Codex on Windows was the reported repro). Each
runtime-root creator now links its host-rendered gstack-office-hours
SKILL.md at office-hours/SKILL.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gbrain-install): name the real fix when an npm-installed bun breaks the shim
Fixes #2487. `npm i -g bun` puts POSIX/cmd/ps1 shims on %PATH% but never
bun.exe — and the gbrain.exe shim that `bun link` generates resolves bun.exe
specifically, so link succeeds and every gbrain call dies with bun's
misleading "bun is not installed in %PATH%" (which suggests installing a
second parallel bun). The D19 validation failure paths now detect the
condition on Windows and print the actual remediation: bun's own
process.execPath IS the hidden bun.exe — add its directory to PATH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(ios-qa): document the bridge compatibility preflight and non-SwiftPM fallback
Re-derived from PR #2581 under the generated-file screening rule (template
hunk taken; SKILL.md regenerated). Prevents the agent from inventing project
wiring on apps the bridge doesn't support (ObservableObject-style or
non-SwiftPM apps): the preflight now names the compatibility check and the
manual fallback path.
Co-authored-by: Tim White <itstimwhite@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(deps): force adm-zip past CVE-2026-39244 via an override
Re-derived from PR #2485 as a resolution override rather than its direct-dep
bump: adm-zip reaches the tree only transitively (onnxruntime-node pins
^0.5.16), so a top-level copy at 0.6.0 would leave onnxruntime-node loading
the vulnerable 0.5.17 — which is exactly what the scanner PR's own lockfile
showed. The override forces every resolution to ^0.6.0.
Co-authored-by: anupamme <anupamme@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* deps: remove unused puppeteer-core; bump transformers/marked/socks
Completes the #2582 split (ENG-OV8). puppeteer-core had ZERO imports
repo-wide — a dead direct dependency whose only footprint was its CVE-prone
transitive chain (puppeteer-core > @puppeteer/browsers > proxy-agent >
get-uri > basic-ftp) and the pin test + basic-ftp override that existed
solely to guard it. Removing the dependency removes the surface: the
basic-ftp override and test/basic-ftp-security-pin.test.ts retire with it
(the lockfile resolves zero basic-ftp copies now). transformers ^4.2.0,
marked ^18.0.9, socks ^2.8.9 land per the dependabot group, gated on the
ONNX sidecar load+classify smoke passing with the bumped transformers
(28/28 sidecar+classifier+security tests green post-bump).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): bump the github-actions group across 1 directory with 10 updates
Bumps the github-actions group with 10 updates in the / directory:
| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/dependency-review-action](https://github.com/actions/dependency-review-action) | `4.9.0` | `5.0.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun) | `1` | `2` |
| [actions/cache](https://github.com/actions/cache) | `4` | `6` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `3adb4b14a2b0623876d18d863a498b785fb3752d` | `f4cfcc01edc9c8b756a9b873b7a623ca674da51e` |
Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)
Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)
Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)
Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)
Updates `actions/dependency-review-action` from 4.9.0 to 5.0.0
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/2031cfc080254a8a887f58cffee85186f0e49e48...a1d282b36b6f3519aa1f3fc636f609c47dddb294)
Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)
Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)
Updates `oven-sh/setup-bun` from 1 to 2
- [Release notes](https://github.com/oven-sh/setup-bun/releases)
- [Commits](https://github.com/oven-sh/setup-bun/compare/v1...v2)
Updates `actions/cache` from 4 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)
Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 3adb4b14a2b0623876d18d863a498b785fb3752d to f4cfcc01edc9c8b756a9b873b7a623ca674da51e
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/3adb4b14a2b0623876d18d863a498b785fb3752d...f4cfcc01edc9c8b756a9b873b7a623ca674da51e)
* fix(test): scope rendered-output tripwires to repo sources; stop cdp-e2e's env leak
Two hermeticity holes surfaced by the wave's final gate. (1) The three T6
tripwires (branch-slug, codex-flag, empty-find) enumerated the whole tree
including the workspace-local .claude/ install, which is not generated
output and can carry dangling symlinks from unrelated sessions — one ENOENT
there failed all three. They now scan repo sources only. (2)
browse/test/cdp-e2e.test.ts mutated process.env.GSTACK_HOME at module scope
without restore; in one-process shard runs that leaks into every later test
file — observed baking cdp-e2e's temp render path into artifacts that
outlived it (53 dangling SKILL.md symlinks in a workspace install). The
original value is now restored in afterAll. The exact test that performed
the polluted relink remains unattributed; both known leak vectors are
closed and the workspace was repaired via an explicit gstack-relink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): honest budget for the suite's one headed persistent-context launch
The launchHeaded/handoff parity test cold-launches a HEADED Chromium — 8-25s
on macOS, worse on the first launch of a freshly downloaded bundle (XProtect
scans it, the #2554 class) and under shard concurrency. bun's 5s default made
it the suite's most reliable false negative: it timed out identically on the
pre-wave baseline run of pristine main. 45s budget; passes 15/15.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): assemble redact fixtures at runtime — the guard caught its own wave
The pre-push redact guard BLOCKED this branch's first push: the wave's new
scan-range tests carried live-FORMAT fake credentials as literals (3 AWS key
shapes + a password-bearing DB URL), and the guard scans pushed diff bytes.
Same dogfood moment as the v1.64 wave, same rule: assemble the fixture at
runtime so the diff never carries a credential shape, never bypass the guard.
Runtime strings stay live-format for the hook under test. The guard works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): sync ios-qa fixture mirrors with the #2585 DEBUG-guard templates
The #2585 absorb updated DebugBridgeTouch.m.template and
Package.swift.template but not their FixtureApp mirrors, failing the
template↔fixture parity gate. DebugBridgeTouch.m syncs byte-for-byte; the
fixture Package.swift takes only the template's new cSettings DEBUG define on
the Touch target (the fixture's own testTarget is fixture-only content the
parity normalization deliberately ignores — a naive full copy breaks the
XCTest invariant). 23/23 including the real swift build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(slug): env-override runs never persist to the cwd cache; cache is GSTACK_HOME-aware
Found while closing the wave's eval gate: a test exporting
GSTACK_PROJECT_SLUG from the repo root persisted the override into the cwd
slug cache, silently rebinding the ENTIRE repo's session state (evals,
decisions, timelines) to the test's slug for every later env-less run. The
escape hatch is per-invocation by contract — it no longer writes the cache.
The cache dir also hardcoded $HOME while lib/bin-context.ts's native port
(#2561) reads it GSTACK_HOME-aware, so temp-home test runs littered the real
~/.gstack (observed: 2,528 stale temp-cwd entries, swept). Writer and reader
now key the same GSTACK_HOME-aware cache; regression tests pin both
behaviors.
Also raises the cso --diff eval budget (240s/25t → 360s/40t):
transcript-verified, the wave's legitimately-grown audit session completes
the report and dies in closing telemetry at ~215s under the old budget; the
full-audit sibling already runs at 300s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): pin GSTACK_HOME in the slug walk-up cache tests
The cache dir became GSTACK_HOME-aware; these tests seed and assert cache
files under a temp HOME but spread the ambient env, so a sibling test
leaking process.env.GSTACK_HOME in a shared-process shard pointed the bin at
a different cache than the one under assertion (AC-2/AC-6 failed in shard
context, passed solo). The env now pins GSTACK_HOME to the temp home —
verified identical results with and without a simulated ambient leak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): the cache-hygiene test strips ambient GSTACK_PROJECT_SLUG
Its env-less contract must be env-less: any ambient override leaking into a
shared-process shard flips the run into override mode, which correctly skips
the cache write the test asserts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): ratchet ship's skeleton cap for the v1.66.1 merge union
Merging main's v1.66.1.0 (evidence-ledger prose in ship's template) on top of
the wave's growth lands ship at 90,333 bytes, 333 over its cap. Re-measured
per the ratchet protocol: cap 90,800.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(brain-sync): throttle + bound the detector push; empty-queue fast path
Review-army findings on the #2549 detector. (1) The preamble runs --once at
every skill boundary, so an unthrottled retry paid a full network push
attempt per boundary in exactly the steady states it targets (offline,
broken auth) — a captive-portal push can block 30-75s against the header's
"<1s when idle" promise. Attempts now stamp .brain-last-push-attempt and
retry at most every 10 minutes; the push never prompts (GIT_TERMINAL_PROMPT=0)
and bounds stalled transfers via git's low-speed limits (portable — stock
macOS has no timeout binary). (2) Author-scoped: only gstack-brain-sync's own
commits retry; a user's manual commit in ~/.gstack rides along on real drains
as before, never auto-published by the detector. (3) Empty-queue fast path
exits before the compute/rewrite python spawns — the steady state is now
cheaper than the pre-wave truncation code. (4) The queue rewrite warns on
failure instead of silently letting the status claim a drain that didn't
happen, counts held unparseable lines, and collapses duplicate lines on
rewrite. Throttle + delivery matrix cases added (37/37).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(version-bump): JSON version-paths get the npm translation; honest recovery messages
Review-army findings. A repo whose package.json carries the legacy 4-digit
mirror and pins it via .gstack/version-path would get "1.67.0.1" written into
a manifest npm rejects forever, with no drift state to catch it (a JSON
source is self-consistent by construction) — the JSON branch now writes the
npm-valid translation, warns when translation occurred, and surfaces the
requested form. Lockfile-failure messages now match reality per failure
point: classify never reads lockfiles, so "re-run and repair" was a false
promise when package.json was written and only the lockfile threw. Both
malformed-version messages read MAJOR.MINOR.PATCH[.MICRO], matching the
3-digit contract this wave ships.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(extension): remove the orphaned security-banner block; repair two dead CSS tokens
Design-review findings. The 197-line .security-banner component (incl. its
keyframes) had no producer — no JS has created the element since the
chat-path rip, the same dead-hidden-security-UI class as the #2557 shield
this wave removed; a tombstone comment points at git history if the banner
UX returns. Two pre-existing token bugs in the mem-toast styles: --zinc-700
was never defined so the button hover computed to transparent (now carries a
fallback), and --font-sans doesn't exist (now --font-system, which :root
defines).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(brain-sync): detector pushes only when ALL unpushed commits are its own; lock released on every exit
The unpushed-commit detector's author check was existential: any bot-authored
commit in origin/<branch>..HEAD armed a push of HEAD, silently publishing
interleaved user-authored commits in ~/.gstack. Now the gate requires the
author-scoped count to equal the total unpushed count — one user commit
disables the autonomous retry entirely (user commits still ride along when a
real drain pushes). Detached HEAD is excluded (origin/HEAD usually resolves,
making the retry a 10-minutely doomed push).
The lock-release trap now installs immediately after lock acquisition instead
of after the empty-queue fast path — the steady state at every skill boundary
leaked the lock dir and relied on stale-PID detection, which PID reuse defeats.
An INT during the detector's network push is covered too.
Matrix test: interleaved user commit blocks the detector, then a real drain
delivers everything.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(version-bump): version-path and package-json-path pins cannot escape the repository
.gstack/version-path and .gstack/package-json-path are repo-controlled
content. A cloned repo pinning '../../victim.json' — or an in-repo symlink
pointing outside — turned a routine bump into an arbitrary file overwrite
outside the repository. assertRepoContained rejects absolute paths, lexical
.. escapes, and symlink escapes (deepest existing ancestor realpath'd, so a
not-yet-created VERSION file is checked through its parent). Lockfiles that
are symlinks resolving outside the repo are skipped with a warning instead
of written through.
Six containment tests including the not-over-broad control (subdirectory
pins keep working).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): port allocator range actually stays below the ephemeral floor; terminal-agent retries a raced bind
RANDOM_PORT_MAX was 60000 while the module header documents 49152-65535 as
the pool to avoid — ~22% of allocations landed back inside it, preserving
the phantom-404 squatting class for both the daemon and the weeks-lived
terminal-agent. The cap is now 49151 and the range test pins the true
property (< 49152) instead of the old <= 60000 tautology.
terminal-agent boot also re-allocates and retries up to 5 times when
Bun.serve throws in the probe-then-bind TOCTOU window — previously a
concurrent bind killed the boot with no retry via main().catch → exit 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex-probe): bash-native watchdog when no timeout binary exists; negative-cache the deterministic model 400
Stock macOS ships neither coreutils gtimeout nor timeout(1); the wrapper's
fallback ran the command unwrapped, so a hung codex exec blocked the probe
and the calling workflow indefinitely. The fallback now backgrounds the
command, TERMs it at the deadline, and mirrors timeout(1)'s exit-124
contract — with the watchdog's stdout detached so an early finish never
blocks a caller's $(...) capture on the orphaned sleep.
MODEL_UNUSABLE is now negative-cached for 15 minutes (same exit-1 + hints
from cache). The deterministic 400 is config-driven, so re-probing every
preflight charged the affected user a 30s round trip plus real tokens per
review section, forever. Editing config.toml — the fix — changes the cache
signature and re-probes immediately; MODEL_PROBE_INCONCLUSIVE stays uncached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): xprotect heal resolves the install root via os.homedir and keeps guidance on a failed retry
With HOME unset, the global-install candidate became the RELATIVE path
.claude/skills/gstack under the daemon's cwd — often an untrusted repo being
QA'd, whose planted node_modules would then be where the heal runs the
playwright install (repo-controlled code execution). os.homedir() plus an
absolute-or-skip guard closes the class.
launchWithXProtectHeal also wraps the post-heal retry: a second classified
failure previously propagated raw, dropping the manual-remediation guidance
exactly when the automatic path had just proven insufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(make-pdf): --strict and --confidential join BOOLEAN_FLAGS; the guard test derives the set from source
Both flags are read as '=== true' booleans but were missing from
BOOLEAN_FLAGS, so 'generate --strict essay.md' still ate essay.md as the
flag's value — the exact #2514 failure the set exists to prevent. The
completeness guard hardcoded six names and could not catch it; it now
derives every boolean read from cli.ts itself (direct reads plus
booleanFlag pairs), so the next boolean flag fails the suite until it
joins the set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): file the v1.67 adversarial-review residuals + coverage-audit test-gap backlog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* v1.67.0.0: version bump (MINOR — full-tracker fix wave, pre-approved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): v1.67.0.0 release summary + itemized changes with contributor credits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(todos): mark the 2026-08-14 tracker-audit waves shipped in v1.67; re-file the four residuals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(uninstall): provenance-gate the shape-2 and cursor sweeps; document the alias-name coupling
Three ways gstack-uninstall could touch a user's own skills:
- Shape 2 (real dir + symlinked SKILL.md) matched the link target against a
bare *gstack* substring, so a skill symlinked from ~/tools/gstack-fork/ was
wiped on uninstall. The gate now requires "gstack" as an anchored path
segment (gstack/*|*/gstack/*, same pattern as shape 1) AND the dir name in
gstack's skill inventory (parity with shape 3); anything else is listed to
stderr, never deleted.
- The new Cursor removals (~/.cursor/skills/gstack* and repo-local
.cursor/skills/gstack*) rm -rf'd any glob match with no provenance check,
so a hand-written ~/.cursor/skills/gstack-fork-notes was swept. Real dirs
now require the AUTO-GENERATED banner in SKILL.md; non-matching dirs are
kept and listed. Legacy codex/factory/kiro globs are untouched (tracked in
TODOS as a follow-up).
- The _INVENTORY seed list hardcodes alias names created by setup's
_install_alias_skill_md; both sites now carry mirrored keep-in-sync
comments so a renamed alias can't silently strand its dir.
The skipped-entry report moves to the end of the run so cursor skips are
listed alongside the Claude ones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(redact): env.kv stops flagging cacheKey-style names; prepush exclusion scoped to the push remote
Two calibration/coverage fixes in the redaction guard:
- env.kv's zero-or-more-prefix regex fired on ANY identifier ending in a
credential suffix, so ordinary code (cacheKey:, sortKey:, partitionKey:,
hotkey:, even monkey:) with an 8+-char entropic value hit a MEDIUM confirm
prompt — a gate that cries wolf gets ignored. A name now only counts when
its shape is credential-semantic: suffix separated by _/-/. (api_key,
x-access-key, AUTH.TOKEN), a bare suffix (key:, token:), ALL-CAPS env style
(APIKEY=, MY_APIKEY=), or a camel compound with a credential prefix
(apiKey, authToken, clientSecret). The value stays capture group 1, so the
shape check lives in validate (isCredentialShapedEnvName), not the regex.
- gstack-redact-prepush's narrowing excluded commits reachable from ANY
remote (`--not --remotes`), so a secret that had only ever reached a
private/local-path remote was never scanned when later pushed to a PUBLIC
remote. The exclusion is now scoped to the push target
(`--remotes=<name>/*`) via the remote name git hands pre-push as $1 (the
installed wrapper already forwards "$@"); stdin/CLI invocations and URL
pushes without a configured name fall back to the historical all-remotes
behavior. #2592's catch-up-merge fix is unaffected: upstream commits come
from the same remote being pushed to.
New coverage: env.kv negative controls (cacheKey/sortKey/partitionKey/
hotkey/monkey/idempotencyKey) + positive controls for all four name shapes;
end-to-end hook tests proving a second-remote secret blocks a push to origin
while origin-published catch-up content still doesn't, plus both fallbacks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): honest probe budget, bounded daemon log, single refusal source, liveness + reinstall coverage
Five hardening items in the browse CLI and its tests:
- probeHealthWithBackoff's advertised ~8s budget could really run ~10s: the
final 2s probe could start 1ms before the deadline, and every call site
had JUST run a failed probe yet the loop re-probed immediately.
Iterations now start with the sleep and each probe's timeout clamps to
the remaining budget (isServerHealthy takes an injectable timeout).
- browse-daemon.log is append-mode across every respawn with no size cap,
so a crash-respawn loop fills the disk. The path is now built in one
place (daemonLogPath — the Unix fd path and the Windows launcher string
had two spellings) and daemon start rotates a >10MB log to
browse-daemon.log.1, single generation, matching the repo's 10MB
rotation convention. Rotation is exported + injectable and behaviorally
unit-tested.
- The two "healthy daemon already running" refusal blocks in connect had
already drifted (one lost the tabs/cookies/logins explainer) — extracted
refuseHeadedOverLiveDaemon as the single source.
- process-liveness: pinned the EPERM-means-alive contract (PID 1 on POSIX,
PID 4 on Windows — signalable-or-EPERM, both alive). A probe that reads
EPERM as dead is the false negative that leaked agents.
- runBoundedChromiumReinstall had zero coverage: now exercised end-to-end
against a stub bunx on a prepended PATH — exit 0, install-exit-N with
stderr tail, the detached group-kill timeout path (child of the child
dies too), and spawn-error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(hooks): timeline Stop hook reads a 256KB tail instead of the whole file
The Stop hook runs on EVERY Claude Code turn machine-wide and re-read +
JSON-parsed the entire timeline each time, scaling to the 10MB size cap
(~100-300ms per turn of pure overhead). It now reads only the last 256KB
via fstat + positioned read, discarding the first partial line when the
window starts mid-file.
Semantics: a dangling "started" older than the last 256KB of appends
belongs to a session long gone — beyond repair interest. The window can
never fabricate a dangling entry ("completed" is always appended AFTER its
"started", so any started inside the window has its completion inside the
window too), so idempotency holds. The fail-open contract is unchanged:
exit 0 always, size cap kept, deadline re-checked before the write.
New test: a >256KB timeline where a recent dangling entry still gets
repaired while an old out-of-window dangler is left alone; all existing
fail-open cases pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): Windows runtime-asset copies prune nested gitignored build output
_link_skill_runtime_assets' exclusion list filters DIRECT children only, so
the Windows cp -R real-copy path swept NESTED gitignored build output into
the installed skill dirs — concretely, ios-qa/scripts/gen-accessors-tool/
.build is 252MB per install. The IS_WINDOWS real-copy branch now prunes
nested node_modules/.build/dist post-copy (find -prune -exec rm -rf).
Scoped to _link_skill_runtime_assets ONLY: the generic _link_or_copy stays
untouched because runtime roots (browse/, design/) intentionally copy their
dist/ binaries. On Unix the assets are symlinks into the working tree, and
the prune is gated on the real-copy shape so it can never delete build
output from the repo through a link — both directions pinned in
test/setup-windows-rerun-refresh.test.ts with fixture trees.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(upgrade): migrations see the real install dir; stash can no longer resurrect stale renders
Two ways the v1.67 render-dirt cleanup was inert in the wired upgrade flow:
- Both migration runners invoked `bash "$migration"` without
GSTACK_INSTALL_DIR, so migrations that clean the INSTALL (v1.67.0.0.sh
defaults to ~/.claude/skills/gstack when unset) silently no-oped for
repo-local installs. setup now passes "$SOURCE_GSTACK_DIR" and the
/gstack-upgrade Step 4.75 runner passes the detected "$INSTALL_DIR".
- /gstack-upgrade Step 4 ran `git stash` BEFORE reset+setup, so the tree
was always clean by the time the migration ran, the legacy render dirt
landed in stash@{0}, and Step 4's own note then told the user to
`git stash pop` — restoring stale generated SKILL.md over the fresh
checkout permanently. Step 4 now discards the render footprint
(generated SKILL.md and sections/*.md modifications only, the same
classification as migrations/v1.67.0.0.sh) BEFORE stashing, so the stash
only ever carries real user changes; the stash-pop note says the render
dirt was discarded and regenerates. The migration stays for manual
git-pull flows.
Template change regenerated for all 3 hosts (claude tree checked in;
codex/factory trees are gitignored render outputs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(browse): stop --force-restart kills the live daemon directly instead of booting a fresh one
`browse stop --force-restart` on a live-but-busy daemon fell through the
stop short-circuit into ensureServer(), whose force-restart path kills the
daemon and then STARTS A FRESH ONE (daemon + Chromium, multi-second churn)
just so sendCommand('stop') can shut it down again — the #2254 churn in
force clothing. gstack-upgrade's Step 4.8 sends users down exactly this
path when a stale daemon is busy after an upgrade.
The stop short-circuit now handles it: live pid + --force-restart → kill
the daemon (tree-kill on Windows, TERM→KILL on POSIX), reap the orphaned
Chromium + clear profile locks, remove the state file, exit 0 — no server
is ever started. Pinned in stop-dead-daemon.test.ts: a wedged live "daemon"
is killed, the state file stays gone (a booted daemon would have rewritten
it), and no Starting/Restarting output appears.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(hooks): timeline repair counts started vs completed per key instead of set-masking
The dangling-event repair kept only the FIRST "started" entry per
skill+session key and treated "completed" as a set, so any key where one
run completed and another dangles was never repaired — and keys are not
unique per run: legacy entries with no session field all share the
bare-skill key, and the preamble's "$$-epoch" session ids collide within
the same second. One old completion masked every future dangler forever.
The hook now counts started vs completed per key and appends completions
for the DIFFERENCE. Idempotency holds by construction: the appended
completions balance the counts, so the next Stop appends nothing. Pinned
with the two-runs-one-dangling case plus a re-run no-op assertion; all
existing fail-open cases pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): Windows refresh bypass no longer deletes a user's own skill dirs
The #2444 IS_WINDOWS refresh bypass (link_codex/factory/opencode/cursor
_skill_dirs) rm -rf's the destination before re-copying — and the host
skills dirs are SHARED namespaces, so the gstack* glob can land on a
user's OWN real directory (e.g. ~/.cursor/skills/gstack-notes). Every
./setup re-run silently deleted it — the ownership guard the comments
still claimed (#2142). The sidecar installers had the same shape against
a hand-written skill squatting on the canonical .../skills/gstack root,
and create_cursor_runtime_root wiped that root unconditionally on every
platform.
Same provenance model as bin/gstack-uninstall (#2563):
- _owned_for_windows_refresh: a real dir is only replaced when its
SKILL.md carries the AUTO-GENERATED banner; symlinks and missing
targets always pass. Non-matching dirs are kept and listed to stderr.
Wired into all four *_skill_dirs loops.
- _sidecar_root_user_owned: a root whose SKILL.md exists WITHOUT the
banner is the user's — create_agents_sidecar, create_cursor_sidecar,
and create_cursor_runtime_root skip it entirely instead of writing
into (or wiping) someone else's skill. A root with no SKILL.md stays
presumed ours (the documented install location; old/partial installs
look like that).
Pinned by a static census (every bypass site must carry its gate) plus
behavior fixtures: a bannerless user dir survives the Windows re-run
while a bannered install still refreshes, and a squatted sidecar root is
left untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(render): a failed brain-aware render can no longer vanish the installed skill set
Both render sites (setup's gbrain step and gstack-config gbrain-refresh)
ran `rm -rf` on the LIVE render dir BEFORE invoking gen:skill-docs:user.
Installed skills symlink into that dir (relink prefers it), so one
transient render failure — bun error, disk full, broken template — left
every brain-aware skill's SKILL.md symlink dangling: the whole skill set
vanished from Claude Code until a successful re-render.
Both sites now render into "$RENDER_DIR.tmp.$$" and swap it in only on
SUCCESS via a shared-contract _swap_in_render helper (mv old away, mv tmp
in, drop old — links into the live path stay valid because the path never
changes). The failure branch removes only the tmp dir and says so: the
previous render, and every link into it, stays fully intact. The
deliberate wipe on the gbrain-GONE path (stale render shadowing canonical
files) is unchanged.
Pinned in test/user-render-out-dir-install.test.ts: static shape (render
targets the TMP dir, never the live dir), _swap_in_render driven
behaviorally from BOTH files, and an end-to-end failure-branch fixture
proving a pre-existing render plus an installed symlink survive a failed
render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test+docs: codex probe cache invalidation coverage, make-pdf --no-* structural pin, file the review-batch deferrals
- test/codex-model-probe.test.ts: the 1h TTL and the auth.json half of the
mtime signature had no coverage — a regression in either would silently
serve a stale MODEL_OK after re-login or forever. Added TTL-expiry
(backdated cache line re-probes) and auth.json-mtime invalidation cases,
mirroring the existing config.toml case.
- make-pdf/test/cli-args.test.ts: structural assertion derived from the
commands.ts registry — every --no-* flag must be in BOOLEAN_FLAGS, so a
new negation flag can't silently re-open #2514 (swallowing the next
positional).
- TODOS.md: filed five review-batch deferrals under the v1.67 queue with
rationale and effort: setup host-function dedup, cmd.exe %VAR% quoting in
gbrainInvocation (cross-spawn direction), make-pdf flag registry metadata
(derive BOOLEAN_FLAGS), legacy codex/factory/kiro uninstall provenance
gating (parity with the cursor gate), and cursor auto-detect breadth
(product call).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): package.json version check accepts the decision-11 npm translation
The bump wrote the npm-valid 3-digit manifest version for the first time
this release; the old assertion demanded byte-equality with the 4-digit
VERSION. Accept the translation plus the grandfathered pre-v1.67 mirror,
matching gstack-version-bump's own drift contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync project documentation with the v1.67.0.0 fix wave
Port range 10000-49151 + busy-vs-dead daemon semantics + XProtect launch
heal + browse-daemon.log in BROWSER.md/ARCHITECTURE.md; #2557 dead security
surface (shield, L4b Haiku, DeBERTa ensemble, canary injector) marked
removed in README/ARCHITECTURE per CLAUDE.md's do-not-redocument note;
runtime-asset installs + alias copies in CONTRIBUTING/CLAUDE.md; manual
uninstall fixed for asset-bearing dirs, alias copies, cursor/opencode
roots, and the timeline Stop hook; gbrain-refresh out-dir render path;
npm-valid package.json version translation documented in CLAUDE.md;
patches/ in the project tree; two CHANGELOG accuracy fixes (-272 net
lines, upgrade-time quarantine-clear) + release-summary em-dash polish.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(browse): findAvailablePort comment matches the 49151 range cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci-image): the dependency layer carries patches/ — bun install needs the patch files the lock declares
bun.lock's patchedDependencies (playwright-core windowsHide) made
'bun install --frozen-lockfile' fail inside the image build: the Dockerfile
copied package.json + bun.lock but not patches/. The image-tag hash in all
three workflows (ci-image, evals, evals-periodic — kept in lockstep) now
includes patches/** so editing a patch rebuilds the layer instead of
serving a stale cache.
Verified: the exact COPY set (package.json + bun.lock + patches) installs
clean in a Linux container; without patches it reproduces the CI failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(codex-probe): cache signature uses GNU-first stat with numeric validation
On GNU stat, -f means FILESYSTEM mode — the BSD-first form emitted a
multi-line filesystem block on Linux, so the cache signature never matched
its own cache line and the model-probe cache missed on every read (each
preflight re-paid the probe). Same class and same fix as #2195: GNU -c %Y
first, BSD -f %m fallback, non-numeric residue coerced to 0.
Verified: the probe test file passes 7/7 under real GNU stat in a Linux
container (it failed 2/7 on Linux CI before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): first cross-platform run of the wave's tests — Linux tmp portability + Windows-lane truthfulness
Four platform holes from the lanes' first full run over the v1.67 tests:
- uninstall neutral-root fallback hardcoded /private/tmp (macOS-only) and
ENOENT'd on Linux CI, where the shard TMPDIR is the gstack-containing
path that forces the fallback — now realpath'd literal /tmp.
- uninstall's kept-and-listed assertion demanded a backslash path on
Windows while the bash uninstall prints POSIX paths — now
separator-insensitive.
- setup-rerun's IS_WINDOWS=0 sub-case and the iron rule's force-restart
consent path are Unix-shaped by construction (Git Bash ln -snf copies
without Developer Mode; the consent path boots a real replacement daemon
the browserless Windows lane cannot host) — gated off win32 with the
reasons in place; the Windows-relevant halves still run there.
- codex-under-codex-detection drives rendered bash under a hardcoded POSIX
PATH, so every case saw empty output on Windows — moved to
KNOWN_WINDOWS_INCOMPATIBLE with the run receipt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci-image): stage patches/ into the narrow build context in all three workflows
The image builds from context .github/docker, into which a staging step
copies package.json + bun.lock — the previous fix added COPY patches to the
Dockerfile but not patches/ to that staging, so buildx failed computing the
COPY checksum ('/patches: not found'). All three workflows (ci-image, evals,
evals-periodic) stage identically, in lockstep with the shared tag hash.
Verified: a build over the exact staged context resolves both COPY layers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Stefan Andrei <89592870+sneakygriff@users.noreply.github.com>
Co-authored-by: Lucky Wenapere <luckydio10@gmail.com>
Co-authored-by: H M Ibtihal Utsho <ibtihal.utsho.ai@gmail.com>
Co-authored-by: ShahriarLak <shahriar.lak1@gmail.com>
Co-authored-by: Mike Laniak <mike.laniak@gmail.com>
Co-authored-by: Yuan Sun <forrest.sun527@gmail.com>
Co-authored-by: Greg Jackson <gregj64@gmail.com>
Co-authored-by: Sebastian Totté <sebastiantotte@gmail.com>
Co-authored-by: IDST UK <IDSTUK@users.noreply.github.com>
Co-authored-by: SomSamantray <SomSamantray@users.noreply.github.com>
Co-authored-by: Mateus Moraes <mmoraes@users.noreply.github.com>
Co-authored-by: Evgenii Lopatin <e75533@gmail.com>
Co-authored-by: Carrington Dennis <carrdenn3@gmail.com>
Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com>
Co-authored-by: Scott <scott@peninsulaminerals.com>
Co-authored-by: SYKhayyat <shaulyoelkhayyat@gmail.com>
Co-authored-by: phuttimatebenchanakatkul <phuttimatebenchanakatkul@gmail.com>
Co-authored-by: vaston-viji <215998886+vaston-viji@users.noreply.github.com>
Co-authored-by: Frederik Kaster <frederik.kaster@noygear.ai>
Co-authored-by: meshailabs <devsupport@meshai.dev>
Co-authored-by: ming <silverchris@foxmail.com>
Co-authored-by: gamerey43 <gamerey43@users.noreply.github.com>
Co-authored-by: tranthanhnhatkhoa <tranthanhnhatkhoa@users.noreply.github.com>
Co-authored-by: harjothkhara <harjothkhara@users.noreply.github.com>
Co-authored-by: chiragborse1 <chiragborse1@users.noreply.github.com>
Co-authored-by: Tim White <itstimwhite@users.noreply.github.com>
Co-authored-by: anupamme <anupamme@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
67 KiB
Browser — Complete Reference
gstack's browser surface in one document. Headless Chromium daemon, ~70+ commands, ref-based element selection, codifiable browser-skills, real-browser mode with a Chrome side panel, an in-sidebar Claude PTY, an ngrok pair-agent flow, and a layered prompt-injection defense — all behind a compiled CLI that prints plain text to stdout. ~100-200ms per call. Zero context-token overhead.
If you've used gstack in the last release or two, the productivity loop is the
new headline: /scrape <intent> drives a page once, /skillify codifies the
flow into a deterministic Playwright script, and the next /scrape on the
same intent runs in ~200ms instead of ~30 seconds of agent re-exploration.
Quick start
# One-time: build the binary (browse/dist/browse, ~58MB)
bun install && bun run build
# Set $B once and forget about it
B=./browse/dist/browse # or ~/.claude/skills/gstack/browse/dist/browse
# Drive a page
$B goto https://news.ycombinator.com
$B snapshot -i # @e refs you can click/fill/inspect later
$B click @e30 # click ref 30 from the snapshot
$B text # get clean page text
$B screenshot /tmp/hn.png
# Codify a repeated flow
/scrape latest hacker news stories
/skillify # writes ~/.gstack/browser-skills/hn-front/...
/scrape hacker news front page # second call: 200ms via the codified skill
# Watch Claude work in real time
$B connect # headed Chromium + Side Panel extension
Table of contents
- What it is
- The productivity loop —
/scrape+/skillify - Architecture
- Command reference
- Snapshot system + ref-based selection
- Browser-skills runtime
- Domain-skills (per-site agent notes)
- Real-browser mode (
$B connect) — including--headed+--proxy+--navigate(v1.28.0.0) - Side Panel + sidebar agent
- Pair-agent — remote agents over an ngrok tunnel
- Authentication + tokens
- Prompt-injection security stack (L1–L6)
- Screenshots, PDFs, visual inspection
- Local HTML —
goto file://vsload-html - Batch endpoint
- Console, network, dialog capture
- JS execution —
js+eval - Tabs, frames, state, watch, inbox
- CDP escape hatch + CSS inspector
- Performance + scale
- Multi-workspace isolation
- Environment variables
- Source map
- Development + testing
- Cross-references
- Acknowledgments
What it is
A compiled CLI binary that talks to a persistent local Chromium daemon over HTTP. The CLI is a thin client — it reads a state file, sends a command, prints the response to stdout. The daemon does the real work via Playwright.
Everything that was a Chrome MCP server in the early days now happens through plain stdout. No JSON-schema framing, no protocol negotiation, no persistent WebSocket — Claude's Bash tool already exists, so we use it.
Three escalating modes:
- Headless (default). Daemon runs Chromium with no visible window. Fastest,
cheapest, what skills like
/qa,/design-review,/benchmarkuse by default. - Headed via
$B connect. Same daemon, but Chromium is visible (rebranded as "GStack Browser") with the Side Panel extension auto-loaded. You watch every command tick through in real time. - Pair-agent over a tunnel. Daemon binds a second listener that ngrok forwards. A remote agent (Codex, OpenClaw, Hermes, anything that can speak HTTP) drives your local browser through a 26-command allowlist with a scoped, single-use token.
The productivity loop
The shipped headline of v1.19.0.0. Two gstack skills wrap the browser-skills runtime so the second time you ask Claude to scrape a page, it runs in ~200ms.
/scrape <intent>
One entry point for pulling page data. Three paths under the hood:
- Match path (~200ms) — agent runs
$B skill list, semantically matches the intent against each skill'striggers:array +description+host, and runs$B skill run <name>if a confident match exists. - Prototype path (~30s) — no match, agent drives the page with
$B goto,$B text,$B html,$B links, etc., returns the JSON, and appends a one-line "say/skillify" suggestion. - Mutating-intent refusal — verbs like submit, click, fill route
to
/automate(Phase 2b, P0 inTODOS.md)./scrapeis read-only by contract.
/skillify
Codifies the most recent successful /scrape prototype into a permanent
browser-skill on disk. Eleven steps, three locked contracts:
- D1 — Provenance guard. Walks back ≤10 agent turns for a clearly-bounded
/scraperesult. Refuses with one specific message if cold. No silent synthesis from chat fragments. - D2 — Synthesis input slice. Extracts ONLY the final-attempt
$Bcalls that produced the JSON the user accepted, plus the user's intent string. Drops failed selectors, drops chat, drops earlier-session content. - D3 — Atomic write. Stages everything to
~/.gstack/.tmp/skillify-<spawnId>/, runs$B skill testagainst the temp dir, and only renames into the final tier path on test pass + user approval. Test fail or rejection:rm -rfthe temp dir entirely. No half-written skill ever appears in$B skill list.
Mutating-flow sibling /automate is split out as P0 in TODOS.md and ships
on the next branch — same skillify machinery, per-mutating-step confirmation
gate when running non-codified.
See docs/designs/BROWSER_SKILLS_V1.md
for the full design + decision trail.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ $B goto https://staging.myapp.com │
│ │ │
│ ▼ │
│ ┌──────────┐ HTTP POST ┌──────────────┐ │
│ │ browse │ ──────────────── │ Bun HTTP │ │
│ │ CLI │ 127.0.0.1:rand │ daemon │ │
│ │ │ Bearer token │ │ │
│ │ compiled │ ◄────────────── │ Playwright │──── Chromium │
│ │ binary │ plain text │ API calls │ (headless │
│ └──────────┘ └──────────────┘ or headed) │
│ ~1ms startup persistent daemon │
│ auto-starts on first call │
│ auto-stops after 30 min idle │
└─────────────────────────────────────────────────────────────────┘
Daemon lifecycle
- First call. CLI checks
<project>/.gstack/browse.jsonfor a running server. None found — it spawnsbun run browse/src/server.tsin the background. Daemon launches headless Chromium via Playwright, picks a random port (10000–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 manualbunx playwright install chromiumguidance lands on daemon stderr (seebrowse-daemon.log). Wired at all three launch sites inbrowser-manager.tsviabrowse/src/xprotect-heal.ts. - Subsequent calls. CLI reads the state file, sends an HTTP POST with the bearer token, prints the response. ~100-200ms round trip.
- Idle shutdown. After 30 minutes of no commands, daemon shuts down and cleans up the state file. Next call restarts it.
- Crash recovery. If Chromium crashes, the daemon exits immediately — no self-healing, don't hide failure. CLI detects the dead daemon on the next call and starts a fresh one.
- Busy vs dead. A daemon that stops answering HTTP while its process is
alive is busy, not dead. The CLI gives
/healtha bounded ~8s to recover, then reports busy with a nonzero exit — it never kills an alive pid. Only an explicit--force-restartreplaces a live-but-unresponsive daemon (tabs, cookies, and logins are lost).browse stopagainst 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
page.locator(scope).ariaSnapshot()returns a YAML-like accessibility tree.- The snapshot parser assigns refs (
@e1,@e2, ...) to each element. - For each ref, it builds a Playwright
Locator(usinggetByRole+ nth-child). - The ref→Locator map is stored on
BrowserManager. - Later commands like
click @e3look up the Locator and calllocator.click().
Ref staleness detection
SPAs can mutate the DOM without navigation (React router, tab switches,
modals). When this happens, refs collected from a previous snapshot may
point to elements that no longer exist. resolveRef() runs an async
count() check before using any ref — if the element count is 0, it throws
immediately with a message telling the agent to re-run snapshot. Fails fast
(~5ms) instead of waiting for Playwright's 30-second action timeout.
Extended snapshot features
--diff(-D). Stores each snapshot as a baseline. On the next-Dcall, returns a unified diff showing what changed. Use this to verify that an action (click, fill, etc.) actually worked.--annotate(-a). Injects temporary overlay divs at each ref's bounding box, takes a screenshot with ref labels visible, then removes the overlays. Use-o <path>to control the output.--cursor-interactive(-C). Scans for non-ARIA interactive elements (divs withcursor:pointer,onclick,tabindex>=0) usingpage.evaluate. Assigns@c1,@c2... refs with deterministicnth-childCSS selectors. These are elements the ARIA tree misses but users can still click.
Browser-skills runtime
Per-task directories that codify a repeated browser flow into a deterministic Playwright script. The compounding layer.
Anatomy of a browser-skill
browser-skills/<name>/
├── SKILL.md # frontmatter + prose contract
├── script.ts # deterministic Playwright-via-browse-client logic
├── _lib/browse-client.ts # vendored copy of the SDK (~3KB, byte-identical to canonical)
├── fixtures/<host>-<date>.html # captured page for fixture-replay tests
└── script.test.ts # parser tests against the fixture (no daemon required)
The bundled reference is browser-skills/hackernews-frontpage/: scrapes the
HN front page, returns 30 stories as JSON. Try it:
$B skill list # shows hackernews-frontpage (bundled)
$B skill show hackernews-frontpage
$B skill run hackernews-frontpage # JSON of 30 stories in ~200ms
$B skill test hackernews-frontpage # runs script.test.ts against fixture
Three-tier storage
$B skill list walks all three in priority order; first hit wins. Resolved
tier is printed inline next to each skill name:
| Tier | Path | When |
|---|---|---|
| Project | <project>/.gstack/browser-skills/<name>/ |
Project-specific skills (committed or gitignored) |
| Global | ~/.gstack/browser-skills/<name>/ |
Per-user skills, all projects |
| Bundled | <gstack-install>/browser-skills/<name>/ |
Ships with gstack, read-only |
Trust model
Two orthogonal axes — daemon-side capability and process-side env — independently configured.
| Axis | Mechanism | Default |
|---|---|---|
| Daemon-side capability | Per-spawn scoped token bound to read+write scope (browser-driving commands minus admin: eval, js, cookies, storage). Single-use clientId encodes skill name + spawn id. Revoked when spawn exits. |
Always scoped — never the daemon root token |
| Process-side env | trusted: true frontmatter passes process.env minus GSTACK_TOKEN. trusted: false (default) drops everything except a minimal allowlist (LANG, LC_ALL, TERM, TZ) and pattern-strips secrets (TOKEN/KEY/SECRET/PASSWORD, AWS_, ANTHROPIC_, OPENAI_, GITHUB_, etc.) |
Untrusted (must opt in) |
GSTACK_PORT and GSTACK_SKILL_TOKEN are injected last, so a parent process
can't override them.
Output protocol
stdout = JSON. stderr = streaming logs. Exit 0 / non-zero. Default 60s
timeout, override via --timeout=Ns. Max stdout 1MB (truncate + non-zero
exit if exceeded). Matches gh / kubectl / docker conventions.
How the SDK distribution works
Each skill ships its own copy of browse-client.ts at _lib/browse-client.ts,
byte-identical to the canonical browse/src/browse-client.ts. /skillify
copies the canonical SDK alongside every generated script. Each skill is
fully self-contained: copy the directory anywhere, it runs. Version drift
impossible — the SDK is frozen at the version the skill was authored against.
Atomic write discipline (/skillify D3)
browse/src/browser-skill-write.ts provides three primitives:
stageSkill(opts)— writes files to~/.gstack/.tmp/skillify-<spawnId>/<name>/with restrictive perms.commitSkill(opts)— atomicfs.renameSyncinto the final tier path. Refuses to follow symlinked staging dirs (lstatcheck), refuses to clobber existing skills, runsrealpathdiscipline on the tier root.discardStaged(stagedDir)—rm -rfthe staged dir + per-spawn wrapper. Idempotent. Called on test failure or approval rejection.
There is no "almost shipped" state. Tests pass + user approves = atomic rename. Tests fail or user rejects = staging vanishes.
See docs/designs/BROWSER_SKILLS_V1.md
for the full design rationale.
Domain-skills
Different mental model from browser-skills: agent-authored notes about a site (not deterministic scripts). One per hostname. Lifecycle:
domain-skill save <host>— agent writes a note about the site (e.g., "GitHub: PR creation needs--draftflag for non-staff", "X.com: timeline uses cursor pagination, not page numbers"). Default state: quarantined.- After N=3 successful uses without the L4 prompt-injection classifier flagging the note, it auto-promotes to active.
domain-skill promote-to-global <host>lifts it to the global tier (machine-wide, all projects).domain-skill rollback <host>demotes;domain-skill rm <host>tombstones.
The classifier flag is set automatically by the L4 prompt-injection scan; agents do not set it manually.
Storage:
- Per-project:
<project>/.gstack/domain-skills/<host>.md - Global:
~/.gstack/domain-skills/<host>.md
Source: browse/src/domain-skills.ts, domain-skill-commands.ts.
Real-browser mode
$B connect launches GStack Browser — a rebranded Chromium controlled by
Playwright with the Side Panel extension auto-loaded and anti-bot stealth
patches applied. You watch every command tick through a visible window in
real time.
$B connect # launches GStack Browser, headed
$B goto https://app.com # navigates in the visible window
$B snapshot -i # refs from the real page
$B click @e3 # clicks in the real window
$B focus # bring window to foreground (macOS)
$B status # shows Mode: cdp
$B disconnect # back to headless mode
The window has a subtle golden shimmer line at the top and a floating "gstack" pill in the bottom-right corner so you always know which Chrome window is being controlled.
What "GStack Browser" means
Not your daily Chrome — a Playwright-managed Chromium with custom branding
in the Dock and menu bar (the .app name, Dock icon, and tray, NOT the UA
string), always-on Layer C anti-bot stealth (most JS-observable automation
tells are masked, so many anti-bot-protected sites load cleanly), a
stock-Chrome user agent that reports the underlying Chromium version, and the
gstack extension pre-loaded via launchPersistentContext. The UA no longer
carries a GStackBrowser suffix — that branding string was itself a
high-entropy tell, so the browser now reports a plain Chrome/<version> UA.
Deepest-layer CDP-protocol detection still gets through (Google can still
trigger captchas; see the CDP-patch item in TODOS.md). Your regular Chrome
with your tabs and bookmarks stays untouched.
When to use headed mode
- QA testing where you want to watch Claude click through your app
- Design review where you need to see exactly what Claude sees
- Debugging where headless behavior differs from real Chrome
- Demos where you're sharing your screen
- Pair-agent sessions (the remote agent drives your local browser)
CDP-aware skills
When in real-browser mode, /qa and /design-review automatically skip
cookie import prompts and headless workarounds — the headed browser already
has whatever session you logged into.
Headed mode + proxy + browser-native downloads (v1.28.0.0)
Three coordinated flags for sites that block headless browsers, fingerprint Playwright defaults, or sit behind authenticated upstream proxies:
# Visible Chromium. Auto-spawns Xvfb on Linux containers without DISPLAY.
$B --headed goto https://example.com
# SOCKS5 with auth — Chromium can't prompt for SOCKS5 creds, so $B runs a
# local 127.0.0.1 bridge that handles the auth handshake.
$B --proxy socks5://user:pass@residential.proxy.host:1080 goto https://example.com
# HTTP/HTTPS proxy passes through to Chromium directly.
$B --proxy http://corp-proxy:3128 goto https://example.com
# Browser-native download for Content-Disposition, redirect chains, anti-bot
# CDNs where page.request.fetch() falls over.
$B download "https://protected.example.com/file" /tmp/file.bin --navigate
# Combined.
$B --headed --proxy socks5://user:pass@host:1080 \
download "https://protected.example.com/file" /tmp/file.bin --navigate
Credential policy. Pass creds via the URL (socks5://user:pass@host) OR
the env vars BROWSE_PROXY_USER / BROWSE_PROXY_PASS — never both. $B
refuses with a clear hint when both are set; silent override created
"works on my machine" debugging traps.
Daemon discipline. --proxy and --headed are daemon-startup config.
A running daemon with config A meeting a new invocation with config B exits
1 with a browse disconnect hint instead of silently restarting and dropping
tab state, cookies, or sessions.
Stealth scope (Layer C, always on). Every context — headless launch,
--headed/--proxy, handoff, and the useragent/viewport --scale
rebuild (recreateContext) — gets the full Layer C mask, no opt-in flag.
Layer C masks navigator.webdriver, restores the window.chrome.* shape
(runtime, app, csi, loadTimes), aligns Notification.permission
with the Permissions API, reports a per-install
hardwareConcurrency/deviceMemory from the host profile, sweeps the known
Selenium/Phantom/Nightmare/Playwright globals, and installs a
Function.prototype.toString proxy so every patched getter reports
[native code] even under the depth-3 recursion check. It still does NOT
fake navigator.plugins or navigator.languages — modern fingerprinters
cross-check those for consistency, and synthesizing fixed values flags MORE
bot-like, not less. ChromeDriver's cdc_/__webdriver runtime artifacts and
the Permissions notifications tell are also cleaned up on every path.
GSTACK_STEALTH=extended (also accepts 1 or true; off by default) layers
six more aggressive patches on top — WebGL renderer spoof, a faked
navigator.plugins PluginArray, navigator.mediaDevices. That mode actively
lies and can break sites that reflect on those properties; use it only when
the default triggers detection. For gbrowser builds with the C++ patches, the
GSTACK_* host-profile env (GPU vendor/renderer, UA-CH platform/model,
hardware) emits the Pack 1 --gstack-gpu-vendor / --gstack-gpu-renderer /
--gstack-ua-platform / --gstack-ua-model / --gstack-hw-concurrency /
--gstack-device-memory switches that push the GPU/UA-CH/hardware spoof down
to native code, and GSTACK_CDP_STEALTH=on (or 1/true) emits the Pack 2
--gstack-suppress-prepare-stack-trace switch (closes the Cloudflare
Error.prepareStackTrace canary). On stock Playwright Chromium every one of
these switches is a safe no-op.
launchHeaded / handoff also strip Playwright's automation-tell launch
defaults via ignoreDefaultArgs (STEALTH_IGNORE_DEFAULT_ARGS):
--enable-automation (the "Chrome is being controlled by automated test
software" infobar), --disable-extensions,
--disable-component-extensions-with-background-pages,
--disable-popup-blocking, --disable-component-update, and
--disable-default-apps.
Container support. --headed on Linux without DISPLAY walks the
display range (:99, :100, ...) until xdpyinfo reports a free slot,
then spawns Xvfb. Cleanup-on-disconnect validates the recorded PID's
/proc/<pid>/cmdline matches Xvfb AND start-time matches before sending
any signal — no PID-reuse footguns. Skips spawn entirely when
WAYLAND_DISPLAY is set (Chromium uses Wayland natively). Standard
Debian/Ubuntu containers work out of the box; minimal images (alpine,
distroless) may need fonts/dbus/gtk libs for headed Chromium to render.
Failure modes. SOCKS5 upstream rejected or unreachable — fail-fast at startup with a redacted error after 3 retries (5s budget). Mid-stream upstream drop — bridge kills the affected client connection only; no transport retries that could corrupt browser traffic.
Side Panel + sidebar agent
The Chrome extension that ships baked into GStack Browser shows a live
activity feed of every browse command in a Side Panel, plus @ref overlays
on the page, plus an interactive Claude PTY inside the sidebar.
The Terminal pane (the headline)
The Side Panel's primary surface is the Terminal pane — a live claude -p
PTY you can type into directly from the sidebar. Activity / Refs / Inspector
are debug overlays behind the footer's debug toggle. WebSocket auth uses
Sec-WebSocket-Protocol (browsers can't set Authorization on a WebSocket
upgrade), and the PTY session token is a 30-minute HttpOnly cookie minted
via POST /pty-session.
The toolbar's Cleanup button and the Inspector's "Send to Code" action both
pipe text into the live Claude PTY via window.gstackInjectToTerminal(text),
exposed by sidepanel-terminal.js. There's no separate /sidebar-command
POST — the live REPL is the only execution surface.
Activity feed
A scrolling feed of every browse command — name, args, duration, status,
errors. Shows up in real time as Claude works. Backed by SSE (/activity/stream)
that accepts the Bearer token OR the HttpOnly gstack_sse session cookie
(30-minute stream-scope cookie minted via POST /sse-session).
Refs tab
After $B snapshot, shows the current @ref list (role + name) so you can
see what Claude is targeting.
CSS Inspector
Powered by $B inspect (CDP-based). Click any element on the page to see the
full CSS rule cascade, computed styles, box model, and modification history.
The "Send to Code" button injects a description into the Claude PTY.
Sidebar architecture
| Component | Where it lives | Notes |
|---|---|---|
| Side Panel UI | extension/sidepanel.js, sidepanel-terminal.js |
Chrome extension surface |
| Background SW | extension/background.js |
Manages tab events, port management |
| Content script | extension/content.js |
Page overlays, gstack pill |
| Terminal agent | browse/src/terminal-agent.ts |
PTY spawn, lifecycle, auth |
| Sidebar utilities | browse/src/sidebar-utils.ts |
URL sanitization, helpers |
Before modifying any of these, read the comment block in CLAUDE.md under
"Sidebar architecture" — silent failures here usually trace to not understanding
the cross-component flow.
Manual install (for your regular Chrome)
If you want the extension in your everyday Chrome (not the Playwright-controlled one):
bin/gstack-extension # opens chrome://extensions, copies path to clipboard
Or do it manually: chrome://extensions → toggle Developer mode → Load
unpacked → navigate to ~/.claude/skills/gstack/extension → pin the
extension → enter the port from $B status.
v1.63 pinned the extension identity via the manifest key field, so existing
unpacked installs get a new extension ID and panel-local state (saved port)
resets once — a one-time in-product notice explains this.
Pair-agent
Remote AI agents (Codex, OpenClaw, Hermes, anything that speaks HTTP) can drive your local browser through an ngrok tunnel. The whole flow is gated by a 26-command allowlist, scoped tokens, and a denial log.
How it works
/pair-agent # generates a setup key, prints connection instructions
# Copy the instructions to the remote agent
# Remote agent runs:
# POST <tunnel-url>/connect with setup key → gets a scoped token (24h, single client)
# POST <tunnel-url>/command with token → runs allowed commands
Dual-listener architecture (v1.6.0.0+)
When pair-agent activates, the daemon binds two HTTP listeners:
- Local listener (
127.0.0.1:LOCAL_PORT). Full command surface. Never forwarded by ngrok. Used by your Claude Code, the Side Panel, anything on your machine. - Tunnel listener (
127.0.0.1:TUNNEL_PORT). Locked allowlist —/connect,/command(scoped tokens + 26-command browser-driving allowlist),/sidebar-chat. ngrok forwards only this port.
Root tokens sent over the tunnel return 403. SSE endpoints use a 30-minute
HttpOnly gstack_sse cookie (never valid against /command).
The 26-command tunnel allowlist
Defined in browse/src/server.ts as TUNNEL_COMMANDS. Pure gate function
canDispatchOverTunnel(command) is exported for unit testing. Set:
goto, click, text, screenshot, html, links, forms, accessibility,
attrs, media, data, scroll, press, type, select, wait, eval,
newtab, tabs, back, forward, reload, snapshot, fill, url, closetab
Notably absent: pair, unpair, cookies, setup, launch, restart,
stop, tunnel-start, token-mint, state, connect, disconnect. A
remote agent that tries them gets a 403 plus a fresh entry in the denial log.
Tunnel denial log
~/.gstack/security/attempts.jsonl — append-only, salted SHA-256 of source
- domain only (no raw IP, no full request body), rotates at 10MB with 5
generations. Per-device salt at
~/.gstack/security/device-salt(mode 0600).
Tunnel egress receipts (v1.63+)
Every tunnel session open writes a hash-chained egress receipt (sink
browse-tunnel) to ~/.gstack/security/egress.jsonl BEFORE ngrok forwards
anything. Fail-closed: if the receipt can't be written, the tunnel listener
is torn down and the start is refused. Inspect the ledger with
bin/gstack-egress list and verify chain integrity with
bin/gstack-egress verify (exit 3 on tamper).
See docs/REMOTE_BROWSER_ACCESS.md for the
full operator guide.
Tab ownership
Scoped tokens default to tabPolicy: 'own-only'. A paired agent can newtab
to create its own tab and drive that tab freely, but it can't goto, fill,
or click on tabs another caller owns. tabs lists ALL tab metadata (an
accepted tradeoff — see ARCHITECTURE.md), but text/html/snapshot content
of unowned tabs is blocked by ownership checks.
Authentication
Three token types, three lifetimes, three scopes.
| Token | Generated by | Lifetime | Scope |
|---|---|---|---|
| Root token | Daemon startup (random UUID) | Daemon process lifetime | Full command surface, local listener only — 403 over tunnel |
| Setup key | POST /pair |
5 minutes, one-time use | Single redemption: present at /connect, get a scoped token |
| Scoped token | POST /connect (with setup key) |
24 hours | Per-client, allowlist-bound, optionally tab-scoped |
The root token is written to <project>/.gstack/browse.json with chmod 600.
Every command that mutates browser state must include
Authorization: Bearer <token>.
SSE session cookie (v1.6.0.0+)
SSE endpoints (/activity/stream, /inspector/events) accept the Bearer
token OR a 30-minute HttpOnly gstack_sse cookie minted via
POST /sse-session. The ?token=<ROOT> query-param auth is no longer
supported. This is what lets the Chrome extension subscribe to the activity
feed without putting the root token in extension storage.
PTY session cookie
The Terminal pane uses a separate session cookie, gstack_pty, minted via
POST /pty-session. Different scope — can spawn / drive the live claude
PTY, can't dispatch arbitrary /command calls. /health endpoint MUST NOT
surface this token.
Extension token bootstrap (v1.63+)
GET /health is liveness/status only — it never carries a token, in any
mode. The Side Panel extension bootstraps the root token via
POST /extension-token on the local listener. The server releases the
token only when the caller's Origin is exactly
chrome-extension://<GSTACK_EXTENSION_ID> — the key field in
extension/manifest.json pins the extension ID (GSTACK_EXTENSION_ID in
browse/src/server.ts; derivation reproducible via
bun browse/scripts/extension-id.ts) — AND the parsed Host hostname is
loopback. Anything else gets a detail-free 403. The endpoint is never
added to TUNNEL_PATHS, so the tunnel surface 404s it by default-deny.
Token registry
browse/src/token-registry.ts handles mint/validate/revoke for all three
types, plus per-token rate limiting. Setup keys are single-use; scoped
tokens have a sliding 24h window; the root token is rotated on each daemon
startup.
Security stack
Layered defense against prompt injection 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-confirmedWARN: 0.75— cross-confirm threshold incombineVerdictLOG_ONLY: 0.40— log-only floorSOLO_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):
$B viewport 480x600 --scale 2
$B load-html /tmp/card.html
$B screenshot /tmp/card.png --selector .card
# .card at 400x200 CSS pixels → card.png is 800x400 pixels
--scale N alone (no WxH) keeps the current viewport size. Scale changes
trigger a context recreation, which invalidates @e/@c refs — rerun
snapshot after. HTML loaded via load-html survives the recreation via
in-memory replay. Rejected in headed mode (real browser controls scale).
PDF generation
pdf accepts the full Playwright surface plus a few additions:
- Layout:
--format letter|a4|legal,--width <dim>,--height <dim>,--margins <dim>,--margin-top/right/bottom/left <dim> - Structure:
--toc(waits for Paged.js if loaded),--outline,--tagged(PDF/A accessibility),--print-background,--prefer-css-page-size - Branding:
--header-template <html>,--footer-template <html>,--page-numbers - Tabs:
--tab-id <N>to render a specific tab - Large payloads:
--from-file <payload.json>(avoids shell argv limits)
Responsive screenshots
responsive [prefix] — three screenshots in one call: mobile (375x812),
tablet (768x1024), desktop (1280x720). Saves as {prefix}-mobile.png etc.
prettyscreenshot
Combines cleanup + scroll + element hide in one call:
$B prettyscreenshot --cleanup --scroll-to "hero section" --hide ".cookie-banner" /tmp/clean.png
Local HTML
Two ways to render HTML that isn't on a web server:
| Approach | When | URL after | Relative assets |
|---|---|---|---|
goto file://<abs-path> |
File already on disk | file:///... |
Resolve against file's directory |
goto file://./<rel>, goto file://~/<rel> |
Smart-parsed to absolute | file:///... |
Same |
load-html <file> |
HTML generated in memory, no parent-dir context needed | about:blank |
Broken (self-contained HTML only) |
Both are scoped to files under cwd or $TMPDIR via the same safe-dirs
policy as eval. file:// URLs preserve query strings and fragments (SPA
routes work).
load-html has an extension allowlist (.html, .htm, .xhtml, .svg) and
a magic-byte sniff to reject binary files mis-renamed as HTML. 50MB size cap
(override via GSTACK_BROWSE_MAX_HTML_BYTES).
load-html content survives later viewport --scale calls via in-memory
replay (TabSession tracks the loaded HTML + waitUntil). The replay is
purely in-memory — HTML is never persisted to disk via state save to
avoid leaking secrets or customer data.
Batch endpoint
POST /batch sends multiple commands in a single HTTP request. Eliminates
per-command round-trip latency — critical for remote agents over ngrok where
each HTTP call costs 2-5s.
POST /batch
Authorization: Bearer <token>
{
"commands": [
{"command": "text", "tabId": 1},
{"command": "text", "tabId": 2},
{"command": "snapshot", "args": ["-i"], "tabId": 3},
{"command": "click", "args": ["@e5"], "tabId": 4}
]
}
Each command routes through handleCommandInternal — full security pipeline
(scope checks, domain validation, tab ownership, content wrapping) enforced
per command. Per-command error isolation: one failure doesn't abort the
batch. Max 50 commands per batch. Nested batches rejected. Rate limiting:
1 batch = 1 request against the per-agent limit.
Pattern: agent crawling 20 pages opens 20 tabs (individual newtab or
batch), then POST /batch with 20 text commands → 20 page contents in
~2-3 seconds total vs ~40-100 seconds serial.
Capture
Console, network, and dialog events flow into O(1) circular buffers (50,000
capacity each), flushed to disk asynchronously via Bun.write():
- Console:
.gstack/browse-console.log - Network:
.gstack/browse-network.log - Dialog:
.gstack/browse-dialog.log
The console, network, and dialog commands read from the in-memory
buffers (not disk) so capture is real-time even when disk is slow.
Dialogs (alert, confirm, prompt) are auto-accepted by default to prevent
browser lockup. dialog-accept <text> controls prompt response text.
JS execution
js runs an inline expression. eval runs a JS file. Both run in the
same JS sandbox — the only difference is inline-vs-file. Both support
await — expressions containing await are auto-wrapped in an async
context:
$B js "await fetch('/api/data').then(r => r.json())" # auto-wrapped
$B js "document.title" # no wrap needed
$B eval my-script.js # file with await
For eval files, single-line files return the expression value directly.
Multi-line files need explicit return when using await. Comments
containing the literal token "await" don't trigger wrapping.
Path safety: eval rejects paths outside cwd or /tmp. js doesn't read
files at all.
Tabs, frames, state
Tabs
$B tabs # list all open tabs
$B tab 3 # switch to tab 3
$B newtab https://example.com # open new tab, switch to it
$B newtab --json # programmatic: returns {"tabId":N,"url":...}
$B closetab # close current
$B closetab 2 # close tab 2
$B tab-each "text" # run "text" on every tab, return JSON
tab-each <command> fans out a command across every open tab and returns a
JSON array — handy for "give me the text of every tab I have open."
Frames
$B frame "#stripe-iframe" # switch to iframe by selector
$B frame @e7 # by ref
$B frame --name "checkout" # by name attribute
$B frame --url "stripe.com" # by URL pattern match
$B frame main # back to top frame
Refs are cleared on switch (the iframe has its own AX tree).
State save/load
$B state save my-session # save cookies + URLs to .gstack/browse-state-my-session.json
$B state load my-session # restore
In-memory load-html content is intentionally NOT persisted (avoid leaking
secrets to disk).
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
$B watch # passive observation: snapshot every 5s while user browses
$B watch stop # return summary of what changed
Useful when you're driving the browser manually and want Claude to see what
you did at the end without spamming snapshot calls.
Inbox
$B inbox # list messages from sidebar scout
$B inbox --clear # clear after reading
The sidebar scout (a background process the Chrome extension can spawn) drops
notes for Claude when the user surfaces something they want noticed. Stored
in .gstack/browser-scout.jsonl.
CDP
$B cdp — raw Chrome DevTools Protocol dispatch
Deny-default. Only methods enumerated in browse/src/cdp-allowlist.ts
(CDP_ALLOWLIST const) are reachable; any other method returns 403. Each
allowlist entry declares scope (tab vs browser) and output (trusted vs
untrusted). Untrusted methods (data-exfil-shaped, e.g.
Network.getResponseBody) get UNTRUSTED-envelope wrapped output.
$B cdp Page.getLayoutMetrics
$B cdp Network.enable
$B cdp Accessibility.getFullAXTree --json '{"max_depth":5}'
To discover allowed methods: read browse/src/cdp-allowlist.ts.
$B inspect — CDP-based CSS inspector
$B inspect ".header" # full rule cascade for the header
$B inspect ".header" --all # include user-agent rules
$B inspect ".header" --history # show modification history
Returns the matched rule cascade with specificity, computed styles, the box
model, and (with --history) every CSS modification made via $B style since
the page loaded. Powered by a persistent CDP session per page in
browse/src/cdp-inspector.ts.
$B ux-audit
$B ux-audit
Returns JSON with site identity, navigation, headings (capped 50), text
blocks, interactive elements (capped 200) — page structure for behavioral
analysis without dumping the full HTML. Used by /qa and /design-review
for cheap coverage maps.
Performance
| Tool | First call | Subsequent calls | Context overhead per call |
|---|---|---|---|
| Chrome MCP | ~5s | ~2-5s | ~2000 tokens (schema + protocol) |
| Playwright MCP | ~3s | ~1-3s | ~1500 tokens (schema + protocol) |
| gstack browse | ~3s | ~100-200ms | 0 tokens (plain text stdout) |
| gstack browse + codified skill | ~3s | ~200ms | 0 tokens (single skill invocation) |
In a 20-command browser session, MCP tools burn 30,000–40,000 tokens on
protocol framing alone. gstack burns zero. The codified-skill path takes a
20-command session down to a single $B skill run call.
Why CLI over MCP
MCP works well for remote services. For local browser automation it adds pure overhead:
- Context bloat — every MCP call includes full JSON schemas. A simple "get the page text" costs 10x more context tokens than it should.
- Connection fragility — persistent WebSocket/stdio connections drop and fail to reconnect.
- Unnecessary abstraction — Claude already has a Bash tool. A CLI that prints to stdout is the simplest possible interface.
gstack skips all of this. Compiled binary. Plain text in, plain text out. No protocol. No schema. No connection management.
Multi-workspace
Each project root (detected via git rev-parse --show-toplevel) gets its
own daemon, port, state file, cookies, and logs. No cross-workspace
collisions.
| Workspace | State file | Port |
|---|---|---|
/code/project-a |
/code/project-a/.gstack/browse.json |
random (10000–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 v1.0+
- Playwright's Chromium (installed automatically by
bun install)
Quick start
bun install # install deps + Playwright Chromium
bun test # all integration tests (~3s for browse-only)
bun run dev <cmd> # run CLI from source (no compile)
bun run build # compile to browse/dist/browse
Dev mode vs compiled binary
During development, use bun run dev instead of the compiled binary. It runs
browse/src/cli.ts directly with Bun, so you get instant feedback:
bun run dev goto https://example.com
bun run dev text
bun run dev snapshot -i
bun run dev click @e3
The compiled binary (bun run build) is only needed for distribution. It
produces a single ~58MB executable at browse/dist/browse using Bun's
--compile flag.
Running tests
bun test # all tests
bun test browse/test/commands # command integration tests
bun test browse/test/snapshot # snapshot tests
bun test browse/test/cookie-import-browser # cookie import unit tests
bun test browse/test/browser-skill-write # D3 atomic-write helper tests
bun test browse/test/tunnel-gate-unit # canDispatchOverTunnel pure tests
Tests spin up a local HTTP server (browse/test/test-server.ts) serving HTML
fixtures from browse/test/fixtures/, then exercise the CLI against those
pages.
Adding a new command
- Add the handler in
read-commands.ts(non-mutating) orwrite-commands.ts(mutating), ormeta-commands.ts(server / lifecycle). - Register the route in
server.ts. - Add the entry to
COMMAND_DESCRIPTIONSinbrowse/src/commands.ts(with a cleardescriptionandusage— thegen-skill-docsvalidation suite enforces no|characters indescription). - Add a test case in
browse/test/commands.test.tswith an HTML fixture if needed. - Run
bun testto verify. - Run
bun run buildto compile. - Run
bun run gen:skill-docsto regenerate SKILL.md (the command appears in the command-reference table downstream).
Adding a new browser-skill
For a hand-written skill: copy browser-skills/hackernews-frontpage/,
update SKILL.md frontmatter, rewrite script.ts against your target site,
re-capture the fixture, update the parser test. bun test validates the
SKILL.md contract (sibling SDK byte-identity, frontmatter schema).
For an agent-written skill: drive the page once with /scrape <intent>,
say /skillify, accept the proposed name in the approval gate. The skill
lands at ~/.gstack/browser-skills/<name>/ after the test passes.
Deploying to the active skill
The active skill lives at ~/.claude/skills/gstack/. After making changes:
cd ~/.claude/skills/gstack
git fetch origin && git reset --hard origin/main
bun run build
Or copy the binary directly:
cp browse/dist/browse ~/.claude/skills/gstack/browse/dist/browse
Cross-references
ARCHITECTURE.md— system-level architecture, dual-listener tunnel design, prompt-injection defense threat modelCLAUDE.md— project-level instructions, sidebar architecture notes, security-stack constraintsdocs/REMOTE_BROWSER_ACCESS.md— operator guide for/pair-agent(setup keys, scoped tokens, denial log)docs/designs/BROWSER_SKILLS_V1.md— design doc for browser-skills runtime (Phase 1 + 2a + roadmap)scrape/SKILL.md—/scrapeskill: match-or-prototype data extractionskillify/SKILL.md—/skillifyskill: codify last/scrapeinto permanent skillTODOS.md—/automate(Phase 2b P0), Phase 3 resolver injection, Phase 4 eval + sandbox
Acknowledgments
The browser automation layer is built on Playwright
by Microsoft. Playwright's accessibility tree API, locator system, and
headless Chromium management are what make ref-based interaction possible.
The snapshot system — assigning @ref labels to AX tree nodes and mapping
them back to Playwright Locators — is built entirely on top of Playwright's
primitives. Thank you to the Playwright team for building such a solid
foundation.
The prompt-injection L4 layer uses
TestSavantAI/distilbert-v1.1-32
(112MB ONNX), 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.