garrytan/gstack
github.com/garrytan/gstack ↗ · 2278 files · 31.9 MB
gstack "I don't think I've typed like a line of code probably since December, basically, which is an extremely large change." — Andrej Karpathy, No Priors podcast, March 2026 When I heard Karpathy say this, I wanted to find out how.
Overview
gstack is Garry Tan's open-source 'AI engineering workflow' toolkit: ~40 slash-command skills (SKILL.md files generated from .tmpl templates) that turn Claude Code and 9 other AI coding agents into a virtual eng team (CEO review, eng review, design, QA, ship, security audit, retro), plus a bundled Playwright/Chromium browse daemon exposed as a compiled `$B` binary for agents that need eyes on a web page. The distinctive architecture is a two-engine browser model — every browser skill drives the user's real Aside browser first (macOS 15+, real cookies/sessions) and falls back automatically to gstack's own persistent headless Chromium daemon — wrapped in an unusually dense security and testing apparatus (scoped tokens, untrusted-content envelopes, an ONNX prompt-injection classifier, hash-chained egress receipts, and a two-tier free/paid test system).
System map
gstack is a Bun/TypeScript monorepo whose primary artifact is a set of generated SKILL.md prompt files installed into AI agent harnesses (Claude Code first, plus Codex, OpenCode, Cursor, Kiro, Factory, Slate, OpenClaw, Hermes, GBrain). Around the skills sit three compiled Bun binaries (browse daemon CLI, make-pdf, design), a ~3.3K-line persistent Chromium daemon (browse/src/server.ts), a Chrome extension sidebar, an iOS-QA daemon with Swift bridge templates, and a supabase telemetry function. The generator (scripts/gen-skill-docs.ts) renders per-host skill docs from .tmpl templates through a RESOLVERS registry; a bash `setup` script builds binaries and links skills per host. Runtime state lives under ~/.gstack (or GSTACK_HOME / CLAUDE_PLUGIN_DATA via bin/gstack-paths) and per-project .gstack/. CI is GitHub Actions: a secretless required free-test lane and a secret-gated paid eval lane.
- The package 'gstack' v1.87.5 publishes two compiled binaries via the `bin` field — `browse` (browse/dist/browse, compiled from browse/src/cli.ts) and `make-pdf` (make-pdf/dist/pdf) — built with `bun build --compile` in the build:gates script.
- The browse engine is a CLI + persistent daemon pair: browse/src/cli.ts is a thin client that reads <project>/.gstack/browse.json (pid/port/token), auto-spawns the server when absent or dead, then POSTs commands to http://127.0.0.1:<port>/command; browse/src/server.ts is a Bun.serve HTTP daemon that dispatches to Playwright-driven Chromium.
- The daemon binds a random port in 10000-49151 via browse/src/port-allocator.ts (deliberately below the macOS ephemeral pool), auto-shuts-down after BROWSE_IDLE_TIMEOUT (default 30 min), and auto-restarts when the binary version in browse/dist/.version differs from the running server's binaryVersion.
- Every skill is a directory at repo root containing SKILL.md.tmpl (plus optional sections/*.md.tmpl carved-out lazy sections) whose committed SKILL.md is generated by scripts/gen-skill-docs.ts; scripts/discover-skills.ts discovers templates by scanning root + one level of subdirs for SKILL.md.tmpl.
- Generation is driven by a RESOLVERS registry (scripts/resolvers/index.ts) mapping ~90 {{PLACEHOLDER}} names to generator functions in domain modules (aside.ts, browse.ts, design.ts, review.ts, testing.ts, gbrain.ts, etc.); each host config can suppress resolvers via suppressedResolvers.
- Host support is declarative: hosts/*.ts configs built by defineHost() in hosts/define-host.ts are registered in hosts/index.ts ALL_HOST_CONFIGS (10 hosts: claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain); the HostConfig interface + validateHostConfig live in scripts/host-config.ts and are consumed by the generator, setup, and tests with no per-host code.
- `./setup` is a 3262-line bash installer (bash-3.2-clean, umask 077) that requires bun, builds the browse/make-pdf/design binaries, regenerates skill docs per host, and symlinks skills into host dirs like ~/.claude/skills or ${CODEX_HOME:-~/.codex}/skills; on Windows it falls back to cp -R copies via the _link_or_copy helper pinned by a test.
- Runtime state roots are resolved by the sourced helper bin/gstack-paths, which emits shell-quoted GSTACK_STATE_ROOT (GSTACK_HOME → CLAUDE_PLUGIN_DATA → ~/.gstack → .gstack), PLAN_ROOT, and TMP_ROOT so skills work as Claude plugin, global install, or CI checkout.
- The Chrome extension (extension/manifest.json, MV3) pins a fixed extension key whose derived ID is the only Origin allowed to mint the daemon root token via POST /extension-token; it provides the headed GStack Browser's sidepanel (activity feed, @ref overlays, PTY terminal).
- The iOS-QA subsystem pairs a Mac-side daemon (ios-qa/daemon/src: index.ts, devicectl.ts, auth-mint.ts, tailscale-localapi.ts, session-tokens.ts) with Swift bridge templates (ios-qa/templates/*.template) injected into the target app, plus CLIs gstack-ios-qa-daemon/-mint/-regen in bin/.
- CI is GitHub Actions with 17 workflows; the merge gates are free-tests.yml (secretless, REQUIRED) and evals.yml (paid, gate-tier). Supporting lanes include windows-free-tests.yml, evals-periodic.yml (weekly EVALS_ALL), quality-gate.yml (secret scan), osv-scanner.yml, skill-docs.yml (generated-doc freshness), and cso-runtime-* image pipelines.
- External integrations: the Aside browser via `aside repl`/`aside exec` CLI (scripts/resolvers/aside.ts), ngrok tunnels via the @ngrok/ngrok dependency for pair-agent, OpenAI image API in design/, Anthropic/OpenAI/Gemini APIs for paid evals, and an optional Supabase community-pulse function for opt-in telemetry.
Feature catalog
The user-facing surface is ~40 slash skills plus a set of compiled/shell CLIs. Skills cluster into plan-mode reviews, implementation+review, release+deploy, ops/memory, browser automation, iOS QA, and safety rails. Under the skills, the browse daemon exposes ~60 commands partitioned into read/write/meta sets, and separate CLIs handle PDF rendering, AI design generation, iOS device bridging, and utility functions (telemetry, config, decisions, learnings, gbrain sync).
- The root SKILL.md.tmpl is a router skill that maps natural-language requests to the right skill (e.g. 'test this site' → /qa, 'ship it' → /ship) and records a ROUTE_OUTCOME telemetry event via bin/gstack-telemetry-log; it honors a persisted PROACTIVE gstack-config flag.
- Plan-mode review skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /plan-devex-review, /plan-tune, /autoplan (CEO → design → DX → eng pipeline), /design-consultation, and /spec (five-phase spec → GitHub issue, optional agent spawn).
- Implementation+review skills: /review (pre-landing PR review), /codex and /claude-code (cross-model second opinions), /investigate (root-cause debugging), /design-review, /design-shotgun, /design-html, /devex-review, /qa and /qa-only (browser QA with fix loop), /scrape + /skillify (codify a scrape into a browser-skill).
- Release+deploy skills: /ship (tests → review → version bump → CHANGELOG → commit → push → PR, with explicit STOP gates enumerated in its template), /land-and-deploy, /canary, /landing-report, /document-release, /document-generate, /setup-deploy, /gstack-upgrade (with migrations/ dir).
- Ops/memory skills: /context-save, /context-restore, /learn, /retro, /health, /benchmark, /benchmark-models, /cso (OWASP+STRIDE audit with a native helper built by scripts/build-cso.sh), /setup-gbrain, /sync-gbrain.
- Browser skills: /browse (Aside first, $B fallback), /open-gstack-browser (headed mode + sidebar), /setup-browser-cookies (import real-browser cookies), /pair-agent (scoped ngrok tunnel for a remote agent); connect-chrome is a kept symlink alias to open-gstack-browser.
- iOS skills /ios-qa, /ios-fix, /ios-design-review, /ios-clean, /ios-sync drive real iPhones over USB CoreDevice + an embedded StateServer, optionally exposed over Tailscale; companion CLIs gstack-ios-qa-daemon, gstack-ios-qa-mint (owner-grant allowlist), gstack-ios-qa-regen ship in bin/.
- Safety skills /careful, /freeze, /guard, /unfreeze install PreToolUse hooks (careful/bin/check-careful.sh warns on destructive commands, freeze/bin/check-freeze.sh hard-blocks edits outside a directory; shared helpers in careful/bin/hook-extract.sh).
- The browse command surface is defined by three sets in browse/src/commands.ts — READ_COMMANDS (text/html/links/js/eval/console/network/...), WRITE_COMMANDS (goto/click/fill/cookie-import-browser/...), META_COMMANDS (tabs/snapshot/pdf/chain/skill/domain-skill/cdp/...) — with a load-time validation loop forcing COMMAND_DESCRIPTIONS to cover exactly the union.
- `$B snapshot` produces an ARIA-tree text view with @e refs (and @c cursor-interactive refs via -C) that later commands resolve to Playwright Locators — no DOM mutation, so it survives CSP, shadow DOM, and framework hydration; refs clear on navigation and resolveRef() does a count() staleness check.
- `$B cdp` is a deny-default raw CDP escape hatch: only methods enumerated with scope + output + justification in browse/src/cdp-allowlist.ts are reachable; untrusted outputs get the UNTRUSTED envelope, enforced by browse/test/cdp-allowlist.test.ts.
- Browser-skills are deterministic Playwright scripts (SKILL.md + script.ts + _lib + fixtures) resolved through a 3-tier project > global > bundled lookup under .gstack/browser-skills/, spawned with a read+write scoped daemon token (never root) and tombstone-based deletion; the sibling `domain-skill` command is a per-site notes store with a quarantine→auto-promote→promote-to-global trust lifecycle tied to the L4 injection classifier.
Data flow
There are four main data pipelines. (1) Doc generation: .tmpl + resolvers + host config → committed SKILL.md per host. (2) Browser commands: CLI → state file → localhost POST /command → scoped-token check → Playwright/CDP → sanitizers + untrusted-content envelope → stdout. (3) Rendering: a local HTML file is served on a loopback Bun.serve port and printed/screenshotted through Aside or the browse daemon. (4) Telemetry/egress: every off-machine send writes a hash-chained receipt before transmitting; eval results land under ~/.gstack/projects/<slug>/evals/. State ownership is split between per-project .gstack/ (daemon state, logs) and the machine-level state root from bin/gstack-paths (sessions, egress ledger, security logs, evals).
- Skill generation pipeline: SKILL.md.tmpl → scripts/gen-skill-docs.ts finds {{PLACEHOLDER}} tokens → RESOLVERS functions produce text → per-host frontmatter transform (allowlist/denylist, descriptionLimit, conditionalFields), pathRewrites and toolRewrites → writes SKILL.md (claude) or <hostSubdir>/skills output (other hosts); --dry-run powers CI freshness checks.
- gen-skill-docs also emits two non-skill generated artifacts: review/design-checklist.md (from lib/design-catalog.ts via scripts/resolvers/design-checklist.ts) and lib/dom-dump.js (from lib/dom-dump-script.ts); test/design-checklist-sync.test.ts fails on drift.
- A browse command flows: cli.ts readState() → isServerHealthy() GET /health → spawn server.ts if absent → POST /command with Authorization: Bearer <token> → server validates token (root or scoped via token-registry checkScope/checkDomain/checkRate) → handleReadCommand/handleWriteCommand/handleMetaCommand → Playwright → result.
- Page-derived output is wrapped as untrusted: PAGE_CONTENT_COMMANDS results pass through wrapUntrustedContent (BEGIN/END UNTRUSTED EXTERNAL CONTENT markers with marker-escape and URL sanitization), and DOM_CONTENT_COMMANDS additionally run markHiddenElements content filters in browse/src/content-security.ts before the read.
- Every server egress of page-content strings routes through Unicode lone-surrogate sanitization: handleCommandInternal wraps the result string, and the SSE producers (/activity/stream, /inspector/events) stringify with sanitizeReplacer — an invariant pinned by browse/test/server-sanitize-surrogates.test.ts.
- Browser event data (console, network, dialog) accumulates in three 50K-entry in-memory CircularBuffers flushed asynchronously to .gstack/*.log every ~1s; the console/network/dialog commands read memory, disk is for post-mortem.
- The render pipeline: bin/gstack-render.ts parses argv into a RenderSpec → lib/aside-render.ts pickEngine() → probeAside() (aside --version + aside repl ASIDE_READY probe) → serve the file's directory on 127.0.0.1 with a per-render secret path segment → one aside repl script emits GSTACK_RENDER_OK + ASIDE_DIR → artifacts copied out; on the fallback path the same spec is executed as newtab/goto/pdf --from-file/screenshot/closetab daemon calls.
- Off-machine sends are receipted before transmission: lib/egress-receipt.ts writeReceipt appends a content-free (sha256+bytes, never payload), hash-chained line to ~/.gstack/security/egress.jsonl (0600); shell sinks use _receipted_curl/_receipted_git from bin/gstack-egress-lib.sh; failure polarity is fail-closed for sensitive sinks and fail-open for user-facing ones.
- Every skill invocation begins by running bin/gstack-skill-start (the consolidated preamble runtime that replaced ~18KB of inline bash): it performs update check, session tracking under ~/.gstack/sessions/, contributor mode, and emits KEY: value STATUS lines the skill prose branches on; bin/gstack-skill-end logs telemetry at completion.
- Paid-eval selection is data-driven: test/helpers/touchfiles.ts re-exports literal-only maps (E2E_TOUCHFILES, E2E_TIERS, LLM_JUDGE_TOUCHFILES, GLOBAL_TOUCHFILES) from touchfiles-data.ts; test-selection.ts computes selectTests() from `git diff` against the detected base branch; results persist via test/helpers/eval-store.ts to ~/.gstack/projects/<slug>/evals/.
- Multi-agent browser access uses scoped tokens: root token → POST /token or POST /connect setup-key exchange → scoped tokens carrying subsets of read/write/admin/meta/control scopes derived-but-reclassified from commands.ts (js/eval/cookies land in admin, not read); control is never granted by default.
- Cookie import reads the real browser's Chromium SQLite cookie DB via a copied temp file opened read-only, decrypts values in-process (PBKDF2 + AES-128-CBC after a user-approved macOS Keychain prompt), loads them into the Playwright context, and never writes plaintext to disk or logs.
Behavior traces
Four flows were traced end-to-end. Install: ./setup checks bun, compiles binaries, regenerates host skill docs, and links skills into the host's skill dir. Browse command: CLI → state file → health probe → daemon spawn → authenticated POST → scope check → Playwright → sanitized untrusted-wrapped output. Local render: gstack-render → pickEngine → loopback static serve → aside repl single script (or daemon command sequence) → artifact copy-out. Paid-eval CI: planner emits a slice manifest from diff selection, six executors run shards, and a fail-closed reconcile job + token-isolated comment job close the lane.
- Install trace: ./setup (bash, set -e, umask 077) → short-circuit --help → require bun on PATH → resolve SOURCE_GSTACK_DIR + per-host skill dirs (CODEX_SKILLS, FACTORY_SKILLS, OPENCODE_SKILLS, CURSOR_SKILLS, KIRO_SKILLS) → scripts/build.sh compiles binaries → gen-skill-docs renders host output → _link_or_copy symlinks skills → prune retired renders (ownership-gated).
- Browse command trace (e.g. `$B snapshot -i`): cli.ts readState() reads .gstack/browse.json → isProcessAlive + isServerHealthy GET /health → on missing/dead daemon spawn server.ts detached (15s/30s-CI start budget via resolveStartTimeout, BROWSE_START_TIMEOUT override) → version-mismatch auto-restart → POST /command Bearer → server.ts buildFetchHandler → canonicalizeCommand → validateScopedToken/checkScope → handleSnapshot → wrap/sanitize → stdout.
- Snapshot→act trace: `snapshot` calls page.accessibility.snapshot(), assigns @e1..@eN refs stored as Map<string, RefEntry> of Playwright Locators on BrowserManager; a later `click @e3` resolves the ref with an async count() staleness check before locator.click(); navigation clears the map via framenavigated.
- Aside drive trace: a skill's BROWSER SETUP runs the generated probe (command -v aside + `aside repl 'console.log("ASIDE_READY " + pwd)'` with gtimeout/timeout/perl fallbacks) → READY | NEEDS_ASIDE | ASIDE_NOT_RUNNING → on READY the skill runs one self-contained `aside repl` script per flow ending in console.log("GSTACK_STEP_OK") and copies artifacts out of the printed ASIDE_DIR.
- Render trace: gstack-render.ts parses flags into RenderSpec → pickEngine/probeAside → Bun.serve binds 127.0.0.1 ephemeral port serving only the file's dir (per-render secret first path segment, realpath containment, no dir listing) → Aside path runs one repl script with goto+wait+pdf/screenshot/eval steps via raw CDP Page.printToPDF → copies artifacts from ASIDE_DIR → prints ENGINE= + OK lines; fallback runs the same spec as daemon calls newtab --json → goto → js-poll → pdf --from-file / screenshot → closetab in a finally.
- Paid-eval CI trace: evals.yml build-image (hash-keyed ghcr image) → plan-slices runs test-paid-shards.ts --emit-plan (EVALS_TIER=gate, 6 slices) → eval-slices matrix executes each slice inside the container with seeded claude config + registered skills → slices-report re-runs the runner with --report to reconcile artifacts vs manifest FAIL-CLOSED (PIPESTATUS pinned) → slices-comment posts the PR comment from a separate job holding the write token and zero repo code.
- Free-test trace: `bun run test` → scripts/test-free-shards.ts enumerates TEST_ROOTS (browse/test, test/, make-pdf/test, design/test) excluding paid files via isPaidTestFile → stable-hash shard assignment → N concurrent `bun test` children → BunTestOutputClassifier strict classification (a shard without bun's terminal summary FAILS; wall-timeout SIGKILL = distinct timed-out) → optional GSTACK_FREE_RETRY_FLAKY attribution-gated retry writing the flake ledger.
- pair-agent tunnel trace: POST /pair (root-only, local listener) mints a setup key → remote agent POSTs /connect on the tunnel listener (rate-limited, unauth) → exchangeSetupKey yields a scoped token (DEFAULT_PAIR_SCOPES = read+write+admin+meta) → tunnel listener serves only /connect + /command with a browser-driving command allowlist; root token on the tunnel = 403; denials are logged to ~/.gstack/security/attempts.jsonl.
- Ship trace: /ship template runs {{PREAMBLE}} (gstack-skill-start) → {{THIRD_PARTY_ACTIONS}} → {{BASE_BRANCH_DETECT}} → {{GBRAIN_CONTEXT_LOAD}} → enumerated gates (on-base-branch abort, conflict stop, test triage, review ASK items, MINOR/MAJOR bump asks) → auto-commit/push/PR.
- Skill invocation runtime trace: generated SKILL.md opens with {{PREAMBLE}} → bin/gstack-skill-start runs update check + session tracking (~/.gstack/sessions/<parent-pid>, 2h prune) → emits STATUS lines → skill executes → bin/gstack-skill-end records telemetry.
- iOS QA trace (inferred from layout): gstack-ios-qa-daemon (ios-qa/daemon/src/index.ts) brokers devicectl over a CoreDevice tunnel; auth-mint/session-tokens manage the tailnet allowlist; Swift templates under ios-qa/templates/ (DebugBridgeManager, StateServer, DebugBridgeTouch) are injected into the target app by gstack-ios-qa-regen. (inferred)
Testing signals
Testing is heavyweight and multi-tier. The free suite (~750 files in test/ plus browse/test, make-pdf/test, design/test — ~8,700 tests) runs under bun test orchestrated by scripts/test-free-shards.ts with strict-output classification; paid LLM-judge and E2E evals spawn real claude/codex/gemini processes through a hermetic env and are diff-selected + tier-classified (gate vs periodic). CI enforces a secretless required free lane, a sliced paid lane on PRs, a curated Windows subset, weekly periodic evals, plus dedicated CSO platform gates. Numerous tripwire tests pin security contracts, generated-file freshness, token budgets, and wiring invariants. Aside-dependent tests self-skip off-macOS.
- Test framework is bun:test with a global preload test-setup.ts that snapshots and restores PATH/Path after every test to kill env-pollution flakes between files in a shared Bun process.
- Free suite runs via scripts/test-free-shards.ts — N concurrent shard processes serial-within, packed by recorded durations (scripts/free-test-durations.json), strict classifier (missing terminal summary = FAIL), wall-clock SIGKILL timeout, exit codes 0/1/124; `bun test` alone is explicitly forbidden because it would load paid eval files.
- Windows runs a curated subset: test-free-shards.ts --windows-only filters out files whose content matches POSIX-only patterns (/bin/bash, sh -c, raw /tmp, chmod, xargs); driven by .github/workflows/windows-free-tests.yml.
- Paid tests are two kinds: LLM-judge evals (test/skill-llm-eval*.test.ts via test/helpers/llm-judge.ts, needs ANTHROPIC_API_KEY) and E2E (test/skill-e2e-*.test.ts spawning real `claude -p` / PTY / Agent SDK / codex / gemini sessions via test/helpers/session-runner.ts, claude-pty-runner.ts, agent-sdk-runner.ts, codex-session-runner.ts, gemini-session-runner.ts).
- E2E children run hermetic: test/helpers/hermetic-env.ts builds an allowlist-scrubbed env (drops CONDUCTOR_*, CLAUDE_*, GSTACK_*, MCP_*, GBRAIN_*, GH_TOKEN; keeps only named auth vars), seeds a fresh CLAUDE_CONFIG_DIR and temp GSTACK_HOME, adds --strict-mcp-config; EVALS_HERMETIC=0 is the debug escape hatch evaluated at call time.
- Diff-based selection: E2E_TOUCHFILES/E2E_TIERS in test/helpers/touchfiles-data.ts map tests to the files they depend on; test-selection.ts selects on `git diff` vs base; EVALS_ALL=1 or :all script variants force everything; test/e2e-tier-alignment.test.ts enforces tier declarations agree with each file's EVALS_TIER self-gate.
- CI gates: free-tests.yml is a REQUIRED secretless check (contents:read, persist-credentials:false, fork PRs get real signal; test/free-tests-workflow-wiring.test.ts fails if a secret sneaks in), aggregating free-suite plus cso-macos/windows/docker jobs into the `free-tests` context.
- Paid lane is a sliced pipeline (planner → 6 executors → fail-closed reconcile) run inside a purpose-built image (.github/docker/Dockerfile.ci, keyed on Dockerfile+bun.lock+patches only); weekly evals-periodic.yml runs EVALS_ALL minus enumerated exclusions in test/helpers/periodic-exclude-data.ts.
- Contract-pinning tests are pervasive: test/aside-driver.test.ts pins the {{ASIDE_SETUP}} sentences, test/design-checklist-sync.test.ts pins generated checklist/dom-dump drift, test/hermetic-wiring.test.ts pins the env scrub, test/egress-receipt-wiring.test.ts scans for unreceipted off-machine sinks, test/setup-windows-fallback.test.ts pins the no-raw-ln invariant in setup.
- Token/context budgets are enforced by tests: test/catalog-budget.test.ts caps aggregate skill frontmatter name+description at 1,150 token-equivalents via test/helpers/skill-census.ts; test/context-budget-ratchet.test.ts pins absolute ceilings against test/fixtures/context-budget.json; bin/gstack-context-bill reports the bill-of-materials.
- Flake management is systematic: GSTACK_FREE_RETRY_FLAKY enables an attribution-gated retry pass whose results append to a flake ledger uploaded as a CI artifact even on green; scripts/eval-flake-rank.ts ranks tests by retried-pass signal; eval-store.ts keeps every retry attempt as its own record.
- Aside-dependent tests (test/skill-e2e-aside.test.ts, Aside qa/design cases, live render in test/aside-render.test.ts) self-skip unless run on a Mac with the Aside app open via asideAvailable(); make-pdf and diagram gates run on whichever engine resolves via browserAvailable().
Risk map
The biggest blast radius is the browse daemon: it executes JS in pages, imports real browser cookies, and can be exposed to the internet via ngrok — mitigated by bearer tokens, scoped sub-tokens, a physically separated tunnel listener, and a deny-default CDP allowlist. Prompt injection is the second major surface, handled by content envelopes, DOM filters, and a sidecar ONNX classifier (but the L5 canary is currently unwired). Other hotspots: the 3.3K-line server.ts, the 3.2K-line bash setup that mutates user home dirs, cookie decryption paths with a documented v20 debugging-port non-goal, fail-open egress sinks, and generated-file drift that relies on sync tests to stay honest.
- The daemon root token is a random UUID stored 0600 in .gstack/browse.json; all mutating endpoints require Bearer auth, but the local listener also serves public surfaces (/cookie-picker, /welcome, /connect probe) — any local process that can read the state file owns the browser.
- pair-agent's ngrok tunnel is the only internet-facing surface; the mitigation is physical port separation — a second 127.0.0.1 listener bound lazily on /tunnel/start serves only /connect + /command with scoped tokens and a command allowlist; root tokens on it get 403 and all denials are rate-capped-logged to ~/.gstack/security/attempts.jsonl.
- Documented non-goal (#1136): cookie-import-browser launches Chrome with --remote-debugging-port=<random>; on Windows with App-Bound Encryption v20 a same-user process can connect to that port and exfiltrate decrypted cookies — an elevation path vs reading the SQLite DB directly.
- Prompt-injection defense is layered: L1-L3 content filters in browse/src/content-security.ts run on every page-content command; L4 is a 22MB int8 BERT ONNX classifier (TestSavantAI) that must run in the security sidecar subprocess (browse/src/security-sidecar-entry.ts) because onnxruntime-node can't dlopen from a bun-compiled binary; L6 combineVerdict requires two classifiers >= WARN to BLOCK on user-authored content.
- The L5 canary-token layer (browse/src/security.ts) currently has no production injector — the chat-path agent that used it was ripped — and L4b transcript classifier was deleted as dead code; defense-in-depth claims in docs exceed what's wired today.
- GSTACK_SECURITY_OFF=1 is a real kill switch for the classifier (L1-L3 filters still run); model cache is 112MB at ~/.gstack/models/testsavant-small/ and the attack log rotates at 10MB x 5 generations — operators auditing security posture need to know the ML layer is optional and locally modelled.
- server.ts is a 3383-line module owning auth, token registry init, both listeners, SSE endpoints, cookie-picker routes, inspector CDP, terminal-agent lifecycle, proxy config, and sanitization — a single blast-radius hotspot where a dispatch bug can cross trust boundaries; several invariants exist only as comment + static-grep tripwire tests. (inferred)
- The sidebar spawns an interactive `claude` PTY (browse/src/terminal-agent.ts) bridged over WebSocket authenticated via Sec-WebSocket-Protocol; any injection into that channel reaches a live agent shell — docs require reading SIDEBAR_MESSAGE_FLOW.md before touching it.
- Egress receipts are forensic, not preventive: fail-open sinks (design OpenAI calls, update-check, dashboards, git-class ops) can send unrecorded when the ledger write fails, and verifyLedger cannot detect tail truncation or whole-file re-fabrication — the threat model explicitly excludes same-user attackers.
- setup is 3262 lines of bash running with umask 077 that writes into ~/.claude, ~/.codex, ~/.factory, ~/.config/opencode, ~/.cursor, ~/.kiro, builds binaries, and manages symlinks — correctness depends on helpers like _link_or_copy and the ownership gate, enforced by static tests rather than types.
- Generated SKILL.md files are committed artifacts; resolving a merge conflict by accepting either side silently drops the other's template changes — the documented fix is resolve .tmpl + re-run gen-skill-docs, but nothing mechanical prevents the bad merge resolution.
- Paid evals spawn real vendor CLIs with real API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY in eval-slices) — cost ~$4.35/run and depend on network/model nondeterminism; hermetic-env mitigates credential leakage but the lane is inherently flaky and secret-gated so fork PRs get no paid signal.
Extension points
The repo is built around registries and discovery conventions. New skills are a directory + SKILL.md.tmpl picked up automatically by discoverTemplates; new hosts are a defineHost() config + one line in hosts/index.ts; new template placeholders are a RESOLVERS entry; new browse commands are additions to the READ/WRITE/META sets + COMMAND_DESCRIPTIONS + a dispatch handler; new CDP methods are allowlist entries with justification; new models are an overlay file + ALL_MODEL_NAMES entry; browser-skills and domain-skills are runtime-extensible stores; sections/*.md.tmpl let a big skill lazy-carve content.
- Add a skill: create <name>/SKILL.md.tmpl at repo root — discoverTemplates() scans root + one subdir level automatically; the generator, validator (test/helpers/skill-parser.ts), skill-check dashboard, and catalog budget all pick it up with zero wiring.
- Add a host: write hosts/<name>.ts calling defineHost() (defaults derive paths, frontmatter allowlist, resolver suppressions, runtimeRoot symlinks from the name), register it in hosts/index.ts ALL_HOST_CONFIGS + re-exports, gitignore the hostSubdir; parameterized smoke tests cover it automatically — documented step-by-step in docs/ADDING_A_HOST.md.
- Add a template placeholder: implement a resolver function taking TemplateContext and register it in the RESOLVERS record (scripts/resolvers/index.ts); optional gated entries ({resolve, appliesTo}) restrict a placeholder to named skills as a structural guardrail.
- Add a browse command: add the name to READ_COMMANDS/WRITE_COMMANDS/META_COMMANDS plus a COMMAND_DESCRIPTIONS entry in browse/src/commands.ts (load-time validation throws on mismatch), then handle it in read-commands.ts / write-commands.ts / meta-commands.ts; aliases go in COMMAND_ALIASES resolved by canonicalizeCommand().
- Add a snapshot flag: extend the SNAPSHOT_FLAGS metadata array in browse/src/snapshot.ts — the {{SNAPSHOT_FLAGS}} resolver renders it into docs from the same source.
- Extend CDP reachability: add a CdpAllowEntry {domain, method, scope, output, justification} to CDP_ALLOWLIST — deny-default means anything not listed 403s, and cdp-allowlist.test.ts lints that every entry has all four fields.
- Add a model family: drop model-overlays/<family>.md and add the name to ALL_MODEL_NAMES in scripts/models.ts; resolveModel() already maps family variants (gpt-5.4-* → gpt-5.4, claude-* → claude).
- Lazy-carve a big skill: put on-demand sections in <skill>/sections/*.md.tmpl with a manifest.json; discoverSectionTemplates renders them with the parent skill's TemplateContext.
- Browser-skills are a runtime plugin store: drop a directory with SKILL.md frontmatter (name/host/triggers/args/trusted) + script.ts into <project>/.gstack/browser-skills/, ~/.gstack/browser-skills/, or the bundled tier — no index file, discovery walks dirs; /skillify generates one from a successful /scrape.
- Domain-skills let agents persist per-site playbooks through `$B domain-skill save|promote-to-global|...` with a quarantine→active→global trust lifecycle tied to the injection classifier.
- Host lifecycle hooks live under hosts/<host>/hooks/ (e.g. hosts/claude/hooks/: AUQ capture, spawned-session directive, timeline stop, Memorable recall bridge) — the pattern for host-specific behavior that isn't skill content.
- The Chrome extension is a fixed-surface extension point: new sidebar panes or content scripts plug into extension/ (sidepanel.js, inspector.js, background.js service worker) but must respect the WS auth + token bootstrap contract pinned by BROWSER_INTERNALS.
Contributor handoff
Read CLAUDE.md first — it is the operational manual (commands, generated-file rules, security invariants). The cardinal rules: edit .tmpl templates never generated SKILL.md; run `bun run test` (the strict shard runner) before commits and `bun run test:evals` before shipping; never run bare `bun test`; keep registry modules side-effect-free; wire egress receipts for any new off-machine send; and respect the token budgets enforced by catalog-budget/context-budget-ratchet tests. Safe edit boundaries: skill prose in .tmpl files, new commands via the commands.ts registry, new hosts via defineHost. Dangerous zones: browse/src/server.ts + extension/ + SSE/CDP paths (read docs/BROWSER_INTERNALS.md first), test/helpers (read docs/TESTING_INTERNALS.md), setup bash invariants.
- First reads for any agent: CLAUDE.md (commands + conventions + security invariants), docs/PROJECT_STRUCTURE.md (annotated tree), ARCHITECTURE.md (why), docs/BROWSER_INTERNALS.md before touching server.ts/extension/SSE/CDP, docs/TESTING_INTERNALS.md before writing E2E or passing env: to runners.
- Generated-file rule: SKILL.md, review/design-checklist.md, lib/dom-dump.js, and agents-digest output are generated — edit the .tmpl or lib source, run `bun run gen:skill-docs`, commit both; never resolve SKILL.md merge conflicts by picking a side.
- Test discipline: `bun run test` (strict shard runner, ~90-100s, ~8,700 tests) before every commit; `bun run test:evals` (paid, diff-based) before shipping; bare `bun test` walks the whole repo including paid files and bypasses the strict classifier — explicitly forbidden.
- Module convention: registry/data modules must have zero side effects on import (commands.ts, token-registry.ts, browser-skills.ts, touchfiles-data.ts are explicitly documented as such) because build scripts and tests import them; literal-only data files (touchfiles-data.ts) must stay importable by old git versions.
- lib/ vs scripts/ boundary: lib/design-catalog.ts documents that bin/ + lib/ travel to every host while scripts/ is never linked — runtime code may not import from scripts/; lib/egress-receipt.ts and browse/src/security.ts are constrained to Node builtins so the compiled binary can embed them.
- New off-machine sends must write an egress receipt (writeReceipt or _receipted_curl/_receipted_git) BEFORE the send, or carry a reasoned SCANNER_EXEMPT entry — test/egress-receipt-wiring.test.ts fails CI on an unreceipted curl/git push/fetch to a non-loopback host.
- Token budgets are ratcheted: new skills must fit catalog-budget (1,150 token-equivalents of frontmatter name+description) and context-budget-ratchet ceilings; legitimate growth requires re-running test/helpers/capture-context-budget.ts and committing the fixture.
- Skill-template conventions: each bash code block in a .tmpl runs in a separate shell (no variable persistence — carry state in prose), never hardcode branch names (use {{BASE_BRANCH_DETECT}}), express conditionals as numbered English steps, and never hardcode project-specific commands (read the target's CLAUDE.md / AskUserQuestion / persist).
- The ONNX classifier can never move into the compiled binary (onnxruntime-node dlopen fails from bun's temp extract dir) — security-classifier.ts stays in security-sidecar-entry.ts; only pure-string pieces live in security.ts.
- State paths must resolve through bin/gstack-paths (eval its output) rather than hardcoding ~/.gstack or ~/.claude — it handles GSTACK_HOME, CLAUDE_PLUGIN_DATA, CLAUDE_PLANS_DIR, and HOME-less containers; claude CLI binary resolution goes through lib/claude-bin.ts with GSTACK_CLAUDE_BIN override.
- Version bumps: VERSION file + package.json version are written by scripts/write-version-files.sh during /ship; detect-bump.ts classifies; the evals CI image deliberately excludes package.json from its hash so version bumps don't rebuild it.
- Known trap: on Windows the Bun-spawned server is broken — cli.ts hard-fails without browse/dist/server-node.mjs built by browse/scripts/build-node-server.sh; and `isProcessAlive` (signal-0, EPERM=alive) is the liveness check while GET /health is the responsiveness check — conflating them caused the busy-daemon bug class.
Languages
| Language | Files | Share of source |
|---|---|---|
| TypeScript | 1351 | 95% |
| JSON | 296 | docs/config |
| Markdown | 253 | docs/config |
| HTML | 30 | docs/config |
| Shell | 29 | 2% |
| YAML | 27 | docs/config |
| JavaScript | 19 | 1% |
| Swift | 10 | 1% |
Start reading here
Probable source entrypoint: lib/ — inferred from the directory tree and manifest, not the upstream docs.
Source roots
lib/ (97 files) gstack/ (1 files)
Repository structure
| Top-level path | Files |
|---|---|
test/ | 1205 |
browse/ | 247 |
scripts/ | 97 |
lib/ | 97 |
bin/ | 89 |
docs/ | 57 |
ios-qa/ | 38 |
make-pdf/ | 37 |
Docs found: AGENTS.mdARCHITECTURE.mdCLAUDE.mdCONTRIBUTING.mdREADME.mddocs/ADDING_A_HOST.mddocs/BROWSER_INTERNALS.mddocs/CHANGELOG_STYLE.mddocs/ON_THE_LOC_CONTROVERSY.mddocs/OPENCLAW.md
Package
package.json — gstack v1.87.5, 12 declared dependencies.
@anthropic-ai/claude-agent-sdk@anthropic-ai/sdk@huggingface/transformers@ngrok/ngrokcross-spawndiffhtml-to-docxmarkedplaywrightsocksxtermxterm-addon-fit
CI & config entrypoints
README.md— Repository readmeAGENTS.md— Agent instructionsCLAUDE.md— Claude instructions.env.example— Required env vars.github/workflows/evals.yml— GitHub Actions workflow.github/workflows/quality-gate.yml— GitHub Actions workflow.github/workflows/evals-periodic.yml— GitHub Actions workflow.github/workflows/dependency-review.yml— GitHub Actions workflow.github/workflows/free-tests.yml— GitHub Actions workflow.github/workflows/windows-free-tests.yml— GitHub Actions workflow
Structural health signals
Average score 7/10 across 96 analyzed source files; 32 hotspots surfaced.
test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift— 610 lines, score 4.5 (Large file, Deep nesting)browse/src/write-commands.ts— 1074 lines, score 4.7 (Large file, Deep nesting)extension/sidepanel-terminal.js— 1007 lines, score 4.7 (Large file, Deep nesting)
These are deterministic review leads — size, churn, and structural signals — not proof of a bug in this project.
Recent history
35dd014c58002026-09-21 — v1.87.5.0 perf: remove idle waits from tests and CI planning (#2897)a6b3a57512ca2026-09-15 — v1.87.4.0 fix: preserve health failures and disclose coverage (#2882)85b8c038fc002026-09-15 — v1.87.3.0 fix: bind review evidence to the reviewed tree (#2875)43c9e45ea7d62026-09-15 — v1.87.2.0 fix: preserve headed browsers during headless cleanup (#2874)6b09a582eb282026-09-15 — v1.87.1.0 fix: update vulnerable sharp and adm-zip overrides (#2873)