Merge origin/main (v1.65.0.0 fork port wave 2) into test-evals-ci-speedup

Second overlapping-wave merge; resolutions compose intent:

- TEST_ROOTS: ours is the superset (main also wired ios-qa/daemon/test;
  ours additionally has ios-qa/scripts + browser-skills). package.json
  'test' keeps routing through the canonical strict runner.
- gbrainAvailable: main fixed the same load-flake with a strictly better
  mechanism (memoized stat-based PATH scan, no subprocess at all) —
  theirs supersedes this branch's memoized-exec probe. Main also made
  the query timeout env-overridable (GSTACK_BRAIN_TIMEOUT_MS).
- Model defaults: adopted main's lib/eval-model.ts abstraction (one
  resolution point, env-overridable per kind) and applied decision D1a
  inside it: capture defaults to Sonnet (Opus opt-in via explicit arg or
  GSTACK_EVAL_MODEL_CAPTURE); test pins updated to follow.
- Parent watchdog: main's rewrite (named parameterized tick, driven
  deterministically by its test via __testInternals__, plus handoff
  suppression semantics from session persistence) supersedes this
  branch's env-tunable interval; adopted their server + test wholesale.
- windows-free-tests: ours (curated bun run test:windows) — main's
  hand-list grew by one more file, which the curated runner subsumes
  automatically; that drift is the reason for D11.
- context-skills 0-for-26 fix: both waves made the IDENTICAL fix; kept
  this branch's comment (carries the receipts).
- .gitignore: main's superset (also ignores Package.resolved — their
  never-commit call; untracked the copy this branch had committed).

Verified: 239-test merge battery green, watchdog 8/8, eval-model 5/5,
actionlint clean, eval:select works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-15 11:56:34 -07:00
commit e99e53cc71
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
200 changed files with 11004 additions and 1207 deletions

44
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,44 @@
<!--
gstack is AI-coded and proud of it. The bar is EVIDENCE OF REAL USE, not lines
of code. A PR with no proof behind it gets closed, no matter how clean it looks.
Fill every section below. See CONTRIBUTING.md → "The evidence bar".
-->
## Why (in your own words)
<!-- One paragraph: what breaks for a user today, and what this change does about
it. Not a restatement of the diff. -->
## Live evidence
<!-- REQUIRED. Paste the command(s) you ran and their real output — before and
after. For a bug: the reproduction, failing then fixed. For a skill change: the
actual transcript / `claude -p` output. For anything visual: before/after
screenshots. "bun test passes" alone is not enough — show the behavior you
changed. -->
```
# what you ran + what it produced
```
## Scope
- **Changed:**
- **Verified live by:**
- **Did NOT test:**
## Liveness proof (required)
<!-- Attach a screenshot of your own machine with the text `GSTACK PR` typed LIVE
into a real surface — terminal prompt, a shell command, your browser
address/search bar, an editor buffer. It must be TYPED INTO A LIVE UI, not drawn,
overlaid, or edited onto the image. A painted-on `GSTACK PR` is an automatic
close. This confirms a human opened this PR. -->
## Checklist
- [ ] Liveness screenshot attached: `GSTACK PR` typed live into a real surface (not edited onto the image)
- [ ] This is not a generated-file-only diff (I edited the source/template and regenerated)
- [ ] No ETHOS.md edits, and no changes to voice / founder perspective / YC references
- [ ] New public command / external service / host adapter has an accepted issue linked (or N/A)
- [ ] Linked issue or reproduction: #

25
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,25 @@
version: 2
updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
groups:
production-dependencies:
dependency-type: "production"
development-dependencies:
dependency-type: "development"
open-pull-requests-limit: 5
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
groups:
github-actions:
patterns:
- "*"
open-pull-requests-limit: 2

46
.github/scripts/gate-secret-scan.mjs vendored Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
const child = spawn("bun", [
"bin/gstack-redact",
"--repo-visibility", "public",
"--json",
"--max-bytes", "16000000",
], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "inherit"] });
let diff = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { diff += chunk; });
process.stdin.once("end", () => {
const additions = diff
.split(/\r?\n/)
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
.map((line) => line.slice(1))
.join("\n");
// The scanner may exit before consuming an oversize payload (it refuses
// stdin over --max-bytes and reports oversize:true). EPIPE here is that
// refusal in flight, not a failure — the report + exit code carry the verdict.
child.stdin.on("error", (error) => {
if (error.code !== "EPIPE") throw error;
});
child.stdin.end(additions);
});
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.once("error", (error) => { throw error; });
child.once("close", (code) => {
let report;
try {
report = JSON.parse(stdout);
} catch {
// No parseable report: the oversize refusal prints only to stderr and
// exits 3, and a crashed scanner emits nothing. Both fail closed.
console.log(`credential scan: 1 high, 0 advisory (scanner emitted no report, exit ${code} — fail-closed)`);
process.exitCode = 1;
return;
}
const high = Number(report.counts?.HIGH ?? 0);
const medium = Number(report.counts?.MEDIUM ?? 0);
console.log(`credential scan: ${high} high, ${medium} advisory`);
process.exitCode = high > 0 || report.oversize || ![0, 2, 3].includes(code) ? 1 : 0;
});

32
.github/workflows/dependency-review.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: Dependency Review
on:
pull_request:
paths:
- 'package.json'
- 'bun.lock'
- '**/package.json'
- '**/bun.lock'
- '.github/workflows/**'
concurrency:
group: dependency-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubicloud-standard-8
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
fail-on-severity: high
fail-on-scopes: runtime, development
comment-summary-in-pr: on-failure

View File

@ -15,6 +15,12 @@ env:
jobs:
# Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change)
build-image:
# Dependabot-triggered pull_request runs get a read-only GITHUB_TOKEN, so
# a lockfile bump = new hash = failed ghcr push = permanently red check
# (EV6, fork port wave 2). Skip the build for dependabot; the evals job's
# needs-chain tolerates it because no eval test selects on a lockfile-only
# diff — a maintainer's next push rebuilds the image with real perms.
if: github.actor != 'dependabot[bot]'
runs-on: ubicloud-standard-8
permissions:
contents: read

26
.github/workflows/osv-scanner.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: OSV Scanner
on:
schedule:
- cron: '23 7 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: osv-scanner
cancel-in-progress: true
jobs:
scan:
permissions:
actions: read
contents: read
security-events: write
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8
with:
scan-args: |-
--include-git-root
--recursive
./

77
.github/workflows/quality-gate.yml vendored Normal file
View File

@ -0,0 +1,77 @@
# Quality gate (fork port wave 2, adapted from time-attack/gstack GStack 2).
#
# Three generic hygiene checks the repo previously had nowhere in CI:
# 1. Credential scan of the PR diff's ADDED lines through our own
# bin/gstack-redact (HIGH fails the check; MEDIUM is an advisory count —
# there is no human in CI to confirm, so it never fails here).
# 2. bun audit at critical severity.
# 3. ShellCheck (errors only) on the setup/build shell boundary.
#
# Trigger is `pull_request`, NEVER `pull_request_target`: fork PRs must not
# get secret-bearing contexts. Diff excludes cover the planted-bug fixtures
# and eval baselines that intentionally contain credential-shaped strings.
name: Quality gate
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: quality-gate-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubicloud-standard-8
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Install frozen dependencies
run: bun install --frozen-lockfile --ignore-scripts
- name: Scan changed text for credentials (added lines, own redact engine)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
BASE_SHA=$(git rev-parse HEAD^)
fi
git diff --unified=0 --no-color "$BASE_SHA" "$HEAD_SHA" -- \
. \
':(exclude)test/fixtures/**' \
':(exclude)browse/test/fixtures/**' \
':(exclude)docs/evals/**' \
':(exclude)test/helpers/security-bench*' \
| node .github/scripts/gate-secret-scan.mjs
- name: Gate critical dependency advisories
run: bun audit --audit-level=critical
- name: Install ShellCheck
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
shellcheck --version
- name: ShellCheck setup and build boundaries
run: >-
shellcheck --severity=error
setup
scripts/build.sh
scripts/build-app.sh
scripts/write-version-files.sh
browse/scripts/build-node-server.sh

4
.gitignore vendored
View File

@ -45,5 +45,7 @@ docs/throughput-*.json
# gbrain local source-staging dir (capability checks, source clones) — runtime artifact
.sources/
# Swift build output from the ios-qa gen-accessors tool (built on demand by its tests)
# SPM build output from the gen-accessors tool (built in place by
# skill-e2e-ios-swift-build; regenerates on every run — never commit)
ios-qa/scripts/gen-accessors-tool/.build/
ios-qa/scripts/gen-accessors-tool/Package.resolved

15
.osv-scanner.toml Normal file
View File

@ -0,0 +1,15 @@
# OSV-Scanner configuration.
# Direct/transitive dependency versions are pinned to their fixed releases via
# the `overrides` block in package.json; this file only records advisories we
# have assessed as not-reachable or not-fixable without disproportionate risk.
[[IgnoredVulns]]
id = "GHSA-frvp-7c67-39w9"
# @hono/node-server 1.19.x. Reachable only through @modelcontextprotocol/sdk,
# which is an unused transitive dependency (no source file imports it) and never
# starts a Hono HTTP server, so the advisory's request path is not exercised.
# The only fix is @hono/node-server 2.0.5, a major bump the MCP SDK pins against
# (^1.19.9); forcing it via override risks breaking the SDK at runtime for a
# vulnerability we do not expose. Re-evaluate if the MCP SDK becomes a direct,
# server-hosting dependency.
reason = "Unreachable transitive (unused @modelcontextprotocol/sdk); fix requires a risky major override on a pinned peer dep."

View File

@ -1091,6 +1091,19 @@ $B state load my-session # restore
In-memory `load-html` content is intentionally NOT persisted (avoid leaking
secrets to disk).
Manual save/load is one-shot. For state that survives daemon restarts
automatically, opt in with `BROWSE_PERSIST_STATE=1` in the daemon's
environment: the headless daemon snapshots cookies + per-tab
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600,
atomic writes) every 30 seconds and at clean shutdown, then restores it off
the boot path on the next launch. Default OFF — cookies on disk are a real
cost, so the user opts in. Headless only (headed mode's persistent Chromium
profile already owns its state). Loaded HTML and tab ownership are never
persisted, cookies for localhost, `.internal`, loopback IP literals
(127.0.0.0/8, `::1`), and link-local/cloud-metadata addresses
(169.254.0.0/16) are dropped on restore, and a corrupt snapshot is quarantined to
`session-state.json.corrupt` so persistence can never block a launch.
### Watch
```bash

View File

@ -1,5 +1,195 @@
# Changelog
## [1.65.0.0] - 2026-08-14
**/autoplan, /codex on macOS, and memory ingest work again.**
**And every consent gate now means what it says.**
This is the second and final wave of the GStack 2 fork port. Wave one (v1.63.0.0)
took the audit infrastructure; this wave takes the fixes and the features. Three
skills that failed silently on every run now work: /autoplan's task aggregation
emits real tasks instead of zero, /codex creates its temp files on macOS instead
of dying on BSD mktemp, and memory ingest actually imports pages on current
gbrain builds, and prints the count so you can see it. On top of that: your
browser auth can now survive a daemon restart, /ship can take an iOS app from
working tree to Submit for Review, and four supply-chain gates now run on every
PR. Nearly all of it traces back to Sina Matian's time-attack/gstack fork, ported
with tests and attribution.
### The numbers that matter
Source: this branch (`git log 1.63.0.0..HEAD`, `git diff main...HEAD --stat`,
`bun test`), plus the GitHub issues the release closes.
| What | Before | After |
|------|--------|-------|
| /autoplan Phase 4 task output (#2018) | 0 tasks, every run | every task |
| /codex on macOS (#2091) | broken on every install | works |
| Memory ingest on gbrain 0.42+ (#2144) | 0 pages, reported success | full corpus, count printed |
| Headed browse on macOS 26 (#2242) | GPU crash, poisoned cache | launches, heals old caches |
| Auth after a browse daemon restart (#778) | logged out | restored (opt-in) |
| CI secret scanning on PR diffs | none | every PR, fail-closed |
| GitHub issues closed | | 24 |
| Community PRs landed with authorship | | 4 |
The stark one is the first three rows: those aren't degraded features, they were
features returning empty results with a green checkmark. If you ran /autoplan in
the last two months, the task list it handed off was empty and nothing told you.
### What this means for gstack users
Run /autoplan and the pipeline hands real tasks to the executor. Run /codex on a
Mac and it just works. Set `BROWSE_PERSIST_STATE=1` and a daemon restart no
longer logs you out of every site. If you ship an iOS app, `/ship` now knows the
whole App Store journey, session-minted upload keys, the price-schedule API that
replaced the broken fastlane path, error -22938 triage, one authorization moment
instead of five. Upgrade with `/gstack-upgrade`; the migration cleans any
Chromium bundle an older gstack broke and verifies the replacement download
before it claims success.
### Itemized changes
#### Added
- **Opt-in browser session persistence** (#778, #2193): `BROWSE_PERSIST_STATE=1`
snapshots cookies and tabs (atomic writes, 0600, never page HTML or
ownership), restores them off the boot path on the next start, and quarantines
a corrupt snapshot instead of crashing. Portions from time-attack/gstack.
- **Apple App Store release journey for /ship**: `ship/sections/apple-release.md`
loads before the repo-landing gates when the target is an Apple app. Encodes
session-minted App Store Connect keys, `appPriceSchedules` over the broken
fastlane `price_tier`, expanded age-rating attributes, -22938 classification,
and a one-authorization-moment flow. Refined across 21 live releases on the
fork. Portions copyright Sina Matian, MIT.
- **Code-intelligence provider contract, Phase 1**: `gstack-code-intelligence`
wraps GBrain, Sourcebot, and Graphify behind one interface with an ask-once
indexing offer for large repos (1,000+ tracked files, decline persisted).
Consent is explicit per repo (`consent <repo> yes|no`), the per-repo trust
policy's deny and read-only tiers veto write-class operations no matter what
consent was recorded, and every off-machine send writes an egress receipt that
records the consent state actually checked. Portions from time-attack/gstack.
- **Supply-chain CI**: a quality gate that runs `bin/gstack-redact` over every
PR diff (HIGH findings fail, MEDIUM annotates), dependency review on
lockfile changes, weekly OSV scans, grouped dependabot updates, and an
evidence-bar PR template. Every third-party action in the new workflows is
pinned to a commit SHA.
- **Third-party web-actions contract** in tier-2+ skills: when a workflow needs
a vendor-site step (API key signup, OAuth app), gstack offers to drive the
browser itself, hands credentials and CAPTCHAs to you, stores secrets
owner-only, and verifies with a read-only call before claiming success.
- **Design docs land in your repo** (#703, #2000): office-hours writes
`docs/designs/<topic>.md` as a concise decision record (one bullet per
decision with its why), redaction-scanned before anything touches your git
history. Plan reviews prefer the repo-local doc when both exist.
- **`gstack-verify-gate`** (opt-in Stop hook): blocks turn-end until the
CLAUDE.md-declared verify command passes. A command runs only after you trust
it once per repo (`--trust`), re-trust is required when it changes, every
grant is audit-logged, and re-entries re-run the check instead of waving it
through.
- **"Never show me these again"** for the founder-resources pitch (#538): the
opt-out verifies its own config write before promising anything. Re-enable
with `gstack-config set founder_resources true`.
- **Claimed limitations need evidence**: every tier-2+ skill now treats "the
API can't do this" as a material claim requiring the verbatim error, the
documented statement, or a live probe, and runs the ten-second check before
declaring anything blocked.
#### Fixed
- **/autoplan Phase 4 emitted zero tasks on every run** (#2018): a jq context
rebind dropped every aggregated task; the error was hidden by stderr
suppression. Six-fixture regression suite pins it.
- **/codex was broken on every macOS install** (#2091): BSD mktemp rejects
suffixed templates; all temp files now use portable templates and a static
test bans the pattern repo-wide.
- **Memory ingest imported nothing on gbrain 0.42+** (#2144): the staging dir
sits under a gitignored tree, so git-aware collectors saw zero files. Fixed
with `--include-gitignored` (community PR #2560) plus a `GIT_CEILING_DIRECTORIES`
second layer, Windows-safe, and a loud ingested-page count.
- **Headed mode on macOS 26** (#2242, #2138, #2139): gstack no longer rewrites
the signed Chrome-for-Testing bundle (the rebrand broke its code signature;
GPU processes refused to start). Launch self-heals poisoned caches, on both
headed entry points, by removing the whole revision directory so the re-fetch
actually re-downloads, and the upgrade migration does the same for existing
installs, verifying a working Chromium exists before recording success.
Branding stays on the GStack Browser wrapper app.
- **`browse stop` restarted the daemon it was told to stop**: the CLI now gets
an acknowledgment before shutdown, and the shutdown snapshot has a hard
deadline so a wedged page can never hold the port.
- **Session cookies from internal networks never reach a restored browser**:
the restore-time hygiene filter drops loopback and link-local IP literals
(127.0.0.1, ::1, 169.254.*) alongside localhost and *.internal, shared by
both the persistence path and `state load`.
- **ios-qa stopped handing out raw bearer tokens**: `/auth/sessions` returns
salted-hash token ids with revoke-by-id support, the boot token left os_log
entirely, and the IPv4 listener pins to loopback at the socket.
- **make-pdf's no-network promise holds against obfuscation**: `<style>`
@import, inline style URLs (quoted, unquoted, CSS-escaped, and
HTML-entity-encoded), srcset, and media sources are all neutralized when
rendering untrusted HTML without `--allow-network`.
- **pair-agent tunnels are consent-gated** (`gstack-config set pair_agent on`):
the tunnel cannot start without the recorded opt-in, the receipts that always
claimed consent now reflect a check that exists, and a disabled gate tells
you the real remedy instead of ngrok install instructions.
- **The per-repo gbrain trust policy is enforced at the code-import chokepoint**
(#2140, sync path): deny refuses, read-only skips code ingest, an unreadable
policy store fails closed, and the egress receipt names the decision.
- **Handoff no longer disarms the tunnel-orphan reaper** (community PR #2565
plus hardening): promoting a daemon to headed suppresses only the
headed-shutdown branch; a daemon with an active tunnel still dies with its
parent.
- **Windows**: broken DACLs on state dirs self-repair (#1605), every
Windows-reachable spawn passes windowsHide (#1835, community PRs #2523 and
#2539), and decision files no longer land under a project named "unknown".
- **Setup no longer hangs on first run** (#2136): the Chromium probe gets a
90-second deadline that reaps the whole wedged process tree, installs are
single-flight across concurrent setups, and EXIT cleanup traps chain instead
of clobbering each other.
- **Paper cuts**: `gh pr edit` falls back to the REST API when the
Projects-classic GraphQL deprecation bites (#1079); the v1.27 migration never
auto-proceeds without a TTY and never records a failed rename as done
(#1383); model benchmarks recognize macOS Keychain auth (#1890); voyage
embedding flags survive zsh (#1798); `--supersede` keeps the replacement
decision it was given; brain context no longer disables itself after one slow
cold-start probe; lock acquisition reports the real error instead of phantom
contention (#1084); plan verification probes the dev server your project
declares instead of a hardcoded port list.
#### Changed
- **Telemetry defaults to off everywhere**: the browse daemon now reads the
same persistent consent the rest of gstack does; an absent key means
disabled, matching `gstack-config get telemetry`.
- **Test-command detection covers Django and config-less projects**: a working
`manage.py test` or `*_test.go` suite is recognized instead of being offered
a bootstrap it doesn't need.
- **Base-branch detection everywhere**: the bins that hardcoded `main` now
probe origin/HEAD, origin/main, and origin/master in order.
- **Eval model resolution is host-neutral**: `GSTACK_EVAL_MODEL` (and per-kind
variants) override the hardcoded model ids at all six call sites.
- Dead bins removed (`chrome-cdp`, `gstack-open-url`, `gstack-platform-detect`);
the stale-reference scan now covers docs/ so removals like these fail CI when
documentation still cites them.
#### For contributors
- 23 new test files (+5,499 test lines): behavioral suites for session
persistence, the poisoned-bundle probe, both migrations, the consent CLI,
verify-gate trust, telemetry opt-out, the offline-gate bypass corpus, the
secret-scan exit contract, and lock-acquisition edge branches. The ios-qa
daemon suite (10 files) is now wired into `bun test` and the sharded runner;
it had never run in CI.
- `lib/gbrain-repo-policy-client.ts` is the one place repo-policy tiers are
read; both enforcement points route through it.
- `lib/context-bill.ts` no longer double-counts nested skills in totalMd, and
`gstack-context-bill` works under Conductor env-shims.
- Egress receipts: `bin/gstack-egress verify` passes with the chain intact;
code-intelligence adapters registered as fail-closed sinks.
- Credits: this release ports work by **Sina Matian** (time-attack/gstack, MIT)
across nearly every cluster. Community PRs absorbed with authorship:
**Gawie van Blerk** (#2560), **Shawn Reddy** (#2565), **Jake Wilk** (#2523),
**Jerry Nichols** (#2539). Thank you all.
## [1.64.1.0] - 2026-08-15
**Every guard in the pipeline now provably fires.**

View File

@ -165,11 +165,11 @@ gstack/
│ ├── test/ # Integration tests
│ └── dist/ # Compiled binary
├── extension/ # Chrome extension (side panel + activity feed + CSS inspector)
├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts)
├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, code-intelligence/)
├── docs/designs/ # Design documents
├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
├── .github/ # CI workflows + Docker image
│ ├── workflows/ # evals.yml (E2E on Ubicloud), skill-docs.yml, actionlint.yml
│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, and 7 more (windows, periodic evals, release gates, ci-image)
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
├── contrib/ # Contributor-only tools (never installed for users)
│ └── add-host/ # /gstack-contrib-add-host skill

View File

@ -271,6 +271,15 @@ Each dimension is scored 1-5. Threshold: every dimension must score **≥ 4**. T
A GitHub Action (`.github/workflows/skill-docs.yml`) runs `bun run gen:skill-docs --dry-run` on every push and PR. If the generated SKILL.md files differ from what's committed, CI fails. This catches stale docs before they merge.
Supply-chain gates run alongside it:
- **Quality gate** (`.github/workflows/quality-gate.yml`, every PR and push) — scans the diff's added lines for credentials using gstack's own redact engine (`.github/scripts/gate-secret-scan.mjs`). HIGH findings fail the job; MEDIUM findings surface as an advisory count. Fails closed if the scan can't produce a report. Also gates critical dependency advisories and runs ShellCheck on the setup/build boundaries.
- **Dependency review** (`.github/workflows/dependency-review.yml`) — reviews dependency changes on PRs that touch lockfiles or workflow files.
- **OSV scanner** (`.github/workflows/osv-scanner.yml`) — weekly vulnerability scan against the OSV database (config in `.osv-scanner.toml`).
- **Dependabot** (`.github/dependabot.yml`) — grouped dependency update PRs.
The supply-chain workflows pin their third-party actions to commit SHAs. The PR template (`.github/PULL_REQUEST_TEMPLATE.md`) asks for evidence — tests run, eval output — not promises.
Tests run against the browse binary directly — they don't require dev mode.
## Editing SKILL.md files

View File

@ -245,6 +245,8 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
| `gstack-egress` | **Egress receipt auditor** — every gstack-initiated off-machine send writes a tamper-evident, hash-chained receipt to `~/.gstack/security/egress.jsonl` before the send. `list` shows what gstack attempted to send and to which host, `grants` shows the standing consent settings plus the exact command that revokes each, `verify` recomputes the hash chain and exits 3 on tamper. |
| `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends file text off-machine; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). |
| `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent <repo> yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. |
| `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: `<!-- gstack:verify: bun test -->`. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |

View File

@ -2,6 +2,88 @@
## NEXT PRIORITY
### P2: Persona-fleet hostile-user harness (fork port wave 2 deferral)
**What:** Port the methodology behind time-attack/gstack's 87-hostile-user
field run (418 findings): machine-written t0 in an append-only run.jsonl
(elapsed time measured, never self-reported), every metric resolving to an
artifact, and a mandatory-quit contract with machine-checkable caps (300s to
first useful output, 900s total, 40K context tokens, 3 consecutive dead ends)
so abandonment is a computable outcome. Specs: fork `evals/fleet/METRICS.md`
+ `evals/fleet/ABANDONMENT.md` (methodology only — no runner code exists to
port; this is a build).
**Why:** A periodic hostile-user round against OUR 44-skill tree would surface
the same first-five-minutes failure class the fork closed 418 of. Fits the
existing eval-store/e2e harness as a new runner.
**Effort:** L (human ~2wk) → M with CC. **Priority:** P2.
**Depends on:** decisions on cost ceilings + journal storage.
### P3: Answer-key eval methodology (rides the persona-fleet work)
**What:** Pre-registered answer keys (fork `evals/answer-keys/`
codex-decorrelation, health-trending) grading our /codex and /health surfaces
against planted ground truth instead of judge vibes.
**Why:** Deterministic scoring for surfaces where LLM-judge drift is the
known failure mode. **Effort:** M → S with CC. **Priority:** P3.
**Depends on:** persona-fleet harness (shared runner shape).
### P3: Quarterly Apple-journey live re-verification
**What:** Run the /ship Apple release adapter against a real (TestFlight-only)
release once a quarter, or on first user bug report, and fix drift. Apple's
APIs move (the fork caught fastlane price_tier breaking live); the adapter's
claims are evidence-backed today and must stay that way per its own
evidence-before-claimed-limitations rule.
**Effort:** S per run. **Priority:** P3. **Depends on:** a paid ADP account.
### P2: office-hours design-doc dual-write functional E2E (fork port wave 2 review shortfall)
**What:** A paid E2E (claude -p) that runs the office-hours Phase 5 handoff in
a tmp repo and asserts BOTH write paths (docs/designs/<topic>.md + the
~/.gstack copy) land and that `bin/gstack-redact` was invoked at the sink.
Today only a static prose pin exists (test/skill-validation.test.ts) — the
plan's R9 asked for the functional shape.
**Why:** The dual-write is an egress path into the user's repo; prose drift
that skips the redact scan-at-sink would ship user PII into git history with
nothing failing. **Effort:** M → S with CC. **Priority:** P2.
**Tier:** periodic (quality, non-deterministic).
### P2: migration runners honor per-migration skip state
**What:** Both migration runners (setup's post-setup block and
/gstack-upgrade Step 4.75) select migrations purely by version window, so a
migration that exits via the non-interactive default-skip (v1.27's
GSTACK_MIGRATE_ASSUME_YES gate) is never offered again — the version marker
advances past it. The remediation text now prints the honest direct
invocation, but the runners should track per-migration .done/.skipped
touchfiles and re-offer pending ones on the next interactive run.
**Why:** Every remaining pre-v1.27 user upgrading via an agent session ([ -t 0 ]
false) permanently misses the artifacts-rename migration unless they paste the
manual command. **Effort:** M. **Priority:** P2.
### P2: periodic tier — three documented-red tests need structural repair
**What:** (1) The sidebar E2E trio (navigate, url-accuracy, css-interaction)
POSTs to /sidebar-command and /sidebar-chat — endpoints removed on every tree
when the PTY terminal replaced the chat queue (server.ts tombstone ~2671);
rewrite them against the PTY surface or delete them. (2)
skill-e2e-ship-idempotency: the PTY child sits at the Claude Code welcome
screen in plan mode for the full budget — the typed /ship never lands
(readiness/typing race vs CLI v2.1.233's welcome screen); never green since
it was born in v1.63. (3) skill-e2e-brain-privacy-gate: never green anywhere;
the artifacts-sync stop-gate preconditions don't survive the hermetic env
even with per-test HOME/GSTACK_HOME injection — needs a transcript-level
debug of what the child's preamble actually echoes.
**Why:** every red periodic run costs triage time; two of these have burned
three triage passes across two releases. **Effort:** M. **Priority:** P2.
### P1: #1882 — portable skill-install prefix (non-`gstack` install dirs break silently)
**What:** Every generated SKILL.md hardcodes the literal `~/.claude/skills/gstack/...`
@ -25,6 +107,9 @@ So #1882 is now purely the body-preamble portability work.
invocation-time failures.
**Cons:** Touches the most load-bearing bash in the repo (every skill's preamble);
a silent mistake breaks all 52 skills. High blast radius — needs its own focused PR.
**Note (fork port wave 2):** the Apple release adapter (ship/sections/
apple-release.md) added template surface with `~/.claude/skills/gstack/bin`
references — include it in this fix's coverage list.
**Context / where to start:**
- Rewire `ctx.paths.binDir` (and browse/design dir paths) + the ~9 resolvers that

View File

@ -1 +1 @@
1.64.1.0
1.65.0.0

View File

@ -682,6 +682,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@ -900,8 +904,21 @@ After /office-hours completes, re-run the design doc check:
setopt +o nomatch 2>/dev/null || true # zsh compat
SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch')
DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
# Repo-local docs win when at least as fresh (#703): office-hours dual-writes
# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates
# see. A stale old repo doc never shadows a newer private session.
_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_REPODOC=""
if [ -n "$_REPOTOP" ]; then
[ -f "$_REPOTOP/DESIGN.md" ] && _REPODOC="$_REPOTOP/DESIGN.md"
[ -z "$_REPODOC" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1)
fi
DESIGN="$_LOCALDOC"
if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then
DESIGN="$_REPODOC"
fi
[ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"
```
@ -1676,12 +1693,12 @@ if command -v jq >/dev/null 2>&1; then
# Filter to current branch + recent commits, then keep records for the
# latest run_id only. (Single phase may have multiple files if the user
# re-ran the review; aggregator takes the newest.)
# NOTE: bind .commit BEFORE the split pipe. Inside ($commits | split(...))
# the "." context is the resulting ARRAY, so a bare .commit there raises
# "Cannot index array with string" on every record — and the 2>/dev/null
# below swallows it, so the whole aggregation silently yields zero tasks.
# .commit must be bound BEFORE piping to the split commit array: a
# pipe rebinds jq's context, so a bare .commit after it indexes the
# ARRAY with a string, every line errors into 2>/dev/null, and the
# aggregate is empty forever — the #2018 zero-tasks bug.
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \
'select(.branch == $branch and ((.commit) as $c | ($commits | split("|") | index($c)) != null))' \
'.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \
"$f" 2>/dev/null >> "$ALL_JSONL" || true
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
# Reduce to latest run_id per phase

View File

@ -1,70 +0,0 @@
#!/bin/bash
# Launch Chrome with CDP (remote debugging) enabled.
# Usage: chrome-cdp [port]
#
# Chrome refuses --remote-debugging-port on its default data directory.
# We create a separate data dir with a symlink to the user's real profile,
# so Chrome thinks it's non-default but uses the same cookies/extensions.
PORT="${1:-9222}"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"
CDP_DATA_DIR="$HOME/.gstack/cdp-profile/chrome"
if ! [ -f "$CHROME" ]; then
echo "Chrome not found at $CHROME" >&2
exit 1
fi
# Check if Chrome is running
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
echo "Chrome is still running. Quitting..."
osascript -e 'tell application "Google Chrome" to quit' 2>/dev/null
# Wait for it to fully exit
for i in $(seq 1 20); do
pgrep -f "Google Chrome" >/dev/null 2>&1 || break
sleep 0.5
done
if pgrep -f "Google Chrome" >/dev/null 2>&1; then
echo "Chrome won't quit. Force-killing..." >&2
pkill -f "Google Chrome"
sleep 1
fi
fi
# Set up CDP data dir with symlinked profile
# Chrome requires a "non-default" data dir for --remote-debugging-port.
# We symlink the real Default profile so cookies/extensions carry over.
mkdir -p "$CDP_DATA_DIR"
if [ -d "$REAL_PROFILE/Default" ] && ! [ -e "$CDP_DATA_DIR/Default" ]; then
ln -s "$REAL_PROFILE/Default" "$CDP_DATA_DIR/Default"
echo "Linked real Chrome profile into CDP data dir"
fi
# Also link Local State (contains crypto keys for cookie decryption, etc.)
if [ -f "$REAL_PROFILE/Local State" ] && ! [ -e "$CDP_DATA_DIR/Local State" ]; then
ln -s "$REAL_PROFILE/Local State" "$CDP_DATA_DIR/Local State"
fi
echo "Launching Chrome with CDP on port $PORT..."
"$CHROME" \
--remote-debugging-port="$PORT" \
--remote-debugging-address=127.0.0.1 \
--remote-allow-origins="http://127.0.0.1:$PORT" \
--user-data-dir="$CDP_DATA_DIR" \
--restore-last-session &
disown
# Wait for CDP to be available
for i in $(seq 1 30); do
if curl -s "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; then
echo "CDP ready on port $PORT"
echo "Run: \$B connect chrome"
exit 0
fi
sleep 1
done
echo "CDP not available after 30s." >&2
exit 1

View File

@ -34,9 +34,9 @@
* gstack-brain-context-load --quiet
*/
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
import { join, dirname, basename, resolve } from "path";
import { execFileSync, spawnSync } from "child_process";
import { existsSync, readFileSync, statSync, readdirSync, accessSync, constants } from "fs";
import { join, dirname, basename, resolve, delimiter } from "path";
import { spawnSync } from "child_process";
import { homedir } from "os";
import { parseSkillManifest, type GbrainManifest, type GbrainManifestQuery, withErrorContext } from "../lib/gstack-memory-helpers";
@ -68,7 +68,9 @@ interface QueryResult {
const HOME = homedir();
const GSTACK_HOME = process.env.GSTACK_HOME || join(HOME, ".gstack");
const MCP_TIMEOUT_MS = 500;
// 500ms hard cap per Section 1C; overridable for slow/loaded environments
// (test harnesses under CI load, cold CLI starts).
const MCP_TIMEOUT_MS = Math.max(1, parseInt(process.env.GSTACK_BRAIN_TIMEOUT_MS || "", 10) || 500);
const PAGE_SIZE_CAP = 10 * 1024; // 10KB per query result before truncation
// ── CLI ────────────────────────────────────────────────────────────────────
@ -190,26 +192,28 @@ function resolveSkillFile(args: CliArgs): string | null {
// ── Dispatchers ────────────────────────────────────────────────────────────
// Memoized: availability can't change mid-invocation, and the per-query
// re-probe was both wasteful (N probes per run) and load-flaky — a cold
// `gbrain --version` on a saturated box can exceed the 500ms budget, branding
// gbrain "missing" for one query while its siblings succeed (observed under
// the parallel free-suite runner: SKIP at dur=505ms with two OKs after it).
let _gbrainAvailable: boolean | null = null;
let gbrainOnPath: boolean | null = null;
function gbrainAvailable(): boolean {
if (_gbrainAvailable !== null) return _gbrainAvailable;
try {
execFileSync("gbrain", ["--version"], {
stdio: "ignore",
// Generous first-probe budget: this runs ONCE, and a slow-to-start CLI
// is not a missing CLI. Query calls keep the tight MCP_TIMEOUT_MS.
timeout: 5_000,
});
_gbrainAvailable = true;
} catch {
_gbrainAvailable = false;
}
return _gbrainAvailable;
// Stat-based PATH scan, memoized. Spawning `gbrain --version` under the
// 500ms budget misreported gbrain as missing whenever a cold process spawn
// exceeded the timeout (loaded machine, node-based CLI cold start), and
// re-probing per query burned 3x the budget before any real work.
if (gbrainOnPath !== null) return gbrainOnPath;
const exts = process.platform === "win32"
? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
: [""];
gbrainOnPath = (process.env.PATH || "").split(delimiter).some((dir) =>
dir !== "" && exts.some((ext) => {
try {
accessSync(join(dir, `gbrain${ext}`), constants.X_OK);
return true;
} catch {
return false;
}
})
);
return gbrainOnPath;
}
function dispatchVector(q: GbrainManifestQuery, args: CliArgs): QueryResult {

284
bin/gstack-code-intelligence Executable file
View File

@ -0,0 +1,284 @@
#!/usr/bin/env bun
/**
* gstack-code-intelligence — pick a code-intelligence provider and use it to
* index and search this repo. OPTIONAL: with nothing selected, gstack works
* fine and callers use grep / the file-only decision store.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Usage:
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
* gstack-code-intelligence options # list providers (GBrain first) + availability
* gstack-code-intelligence status # current selection + availability
* gstack-code-intelligence consent [repo-path] <yes|no> # record per-repo indexing consent (value REQUIRED)
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
* gstack-code-intelligence search <query...> # search via the selected provider
*
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
* nothing leaves the machine, so no consent is needed. Graphify is never
* auto-installed.
*/
import { createHash } from "crypto";
import { realpathSync } from "fs";
import { hostname } from "os";
import { basename, resolve } from "path";
import {
CodeProviderError,
RECOMMENDED_ORDER,
detectAvailable,
getRoot,
hasConsent,
providerById,
readSelection,
resolveSelectedProvider,
setConsent,
setProvider,
setRoot,
shouldOfferIndexing,
type CodeProviderId,
} from "../lib/code-intelligence";
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
const NOTE: Record<CodeProviderId, string> = {
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
};
function out(s: string): void {
process.stdout.write(`${s}\n`);
}
function fail(s: string): never {
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
process.exit(1);
}
async function cmdOptions(): Promise<void> {
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
const avail = await detectAvailable();
const byId = new Map(avail.map((a) => [a.id, a]));
for (const id of RECOMMENDED_ORDER) {
const a = byId.get(id);
const mark = a?.available ? "available" : "not available";
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
if (a?.detail) out(` ${a.detail}`);
}
out("\nSelect one with: gstack-code-intelligence select <provider>");
}
async function cmdStatus(): Promise<void> {
const sel = readSelection();
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
const avail = await detectAvailable();
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
}
/**
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
* agent should ask the user about indexing this repo, and when it should, the
* provider options with their reasons so the question is self-contained.
*/
async function cmdSuggest(rest: string[]): Promise<void> {
const json = rest.includes("--json");
const pathArg = rest.find((a) => !a.startsWith("--"));
const repoPath = resolve(pathArg ?? process.cwd());
const suggestion = shouldOfferIndexing(repoPath);
if (!suggestion.offer) {
if (json) {
out(JSON.stringify({ ...suggestion, repoPath }));
} else {
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
}
return;
}
const avail = await detectAvailable();
if (json) {
out(JSON.stringify({
...suggestion,
repoPath,
options: avail.map((a) => ({
id: a.id,
label: LABEL[a.id],
reason: NOTE[a.id],
local: providerById(a.id).local,
available: a.available,
detail: a.detail,
})),
}));
return;
}
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
await cmdOptions();
}
function cmdSelect(arg: string | undefined): void {
if (arg === "none") {
setProvider(null);
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
return;
}
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
fail("Usage: select <gbrain|sourcebot|graphify|none>");
}
const id = arg as CodeProviderId;
setProvider(id);
out(`selected ${LABEL[id]}.`);
const provider = providerById(id);
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
}
/**
* Record per-repo indexing consent: `consent [repo-path] <yes|no>`.
*
* The yes|no value is REQUIRED (true/false also accepted). It is never
* defaulted: an agent recording a user's "no" must persist consent DENIED,
* and a missing/unknown value must record NOTHING — a consent gate that
* assumes "yes" is a consent gate that lies.
*/
function cmdConsent(rest: string[]): void {
const positional = rest.filter((a) => !a.startsWith("--"));
const CONSENT_USAGE = "Usage: consent [repo-path] <yes|no> — the yes/no value is required; consent is never assumed";
if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE);
const value = positional[positional.length - 1].toLowerCase();
let consented: boolean;
if (value === "yes" || value === "true") consented = true;
else if (value === "no" || value === "false") consented = false;
else fail(CONSENT_USAGE);
const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd());
setConsent(repoPath, consented);
out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`);
}
/**
* Host+path-hashed source id for GBrain/Sourcebot — the same approach as
* deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides:
* two repos both named "api" (or the same repo on two machines against a
* federated brain) would silently share one source. Suffix = first 8 hex of
* sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id
* charset (lowercase alnum + interior hyphens) and capped so the whole id
* stays within gbrain's 32-char limit.
*/
function hashedSourceId(repoPath: string): string {
let real = repoPath;
try {
real = realpathSync(repoPath);
} catch {
// path may not exist yet at id-derivation time — hash the resolved form
}
const host = process.env.GSTACK_HOSTNAME || hostname();
const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8);
const base =
basename(real)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 23)
.replace(/-+$/, "") || "repo";
return `${base}-${suffix}`;
}
async function cmdIndex(pathArg: string | undefined): Promise<void> {
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first");
const repoPath = resolve(pathArg ?? process.cwd());
// Indexing is write-class: hasConsent's default op class applies, so a
// `deny` OR `read-only` repo trust policy vetoes it (code indexing writes
// pages — same semantics as gstack-gbrain-sync's runCodeImport).
const consented = hasConsent(repoPath);
if (!provider!.local && !consented) {
const recorded = readSelection().consents[repoPath] === true;
fail(recorded
? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set <origin-url> read-write`
: `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`);
}
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short
// host+path-hashed id (bare basenames collide across same-named repos).
const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath);
const repo = { id: sourceId, path: repoPath };
try {
const registered = await provider!.registerSource(repo, { consented });
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
// Remember which repo this provider indexed so `search` reads the same graph.
setRoot(provider!.id, repoPath);
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
async function cmdSearch(terms: string[]): Promise<void> {
const query = terms.join(" ").trim();
if (!query) fail("Usage: search <query...>");
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
// Search is read-class: a read-only repo trust policy still allows it
// (mirrors gstack-gbrain-sync: search allowed, page writes never), but a
// deny tier — or no recorded consent at all — still refuses for non-local
// providers, because the query text itself is repo-derived content. The
// consent repo is the one this provider indexed (search reads that graph);
// loopback providers need no consent, so their path is unchanged.
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
const consented = hasConsent(searchRoot, undefined, "read");
// Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate
// (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or
// receipt exist), but the CLI names WHY — missing consent vs a deny repo
// trust policy — instead of surfacing a generic provider error.
if (!provider!.local && !consented) {
const recorded = readSelection().consents[searchRoot] === true;
fail(recorded
? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set <origin-url> read-only (search allowed) or read-write`
: `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`);
}
try {
const hits = await provider!.search(query, { limit: 10, consented });
if (!hits.length) {
out("(no results)");
return;
}
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
function handleProviderError(err: unknown, label: string): never {
if (err instanceof CodeProviderError) {
if (err.code === "PROVIDER_UNAVAILABLE") {
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
}
if (err.code === "PROVIDER_NOT_CONSENTED") {
fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent <repo-path> yes\` first (a deny repo trust policy overrides recorded consent).`);
}
fail(`${label} ${err.code}: ${err.message}`);
}
fail(err instanceof Error ? err.message : String(err));
}
async function main(): Promise<void> {
const [action, ...rest] = process.argv.slice(2);
switch (action) {
case "suggest":
return cmdSuggest(rest);
case "options":
return cmdOptions();
case "status":
return cmdStatus();
case "select":
return cmdSelect(rest[0]);
case "consent":
return cmdConsent(rest);
case "index":
return cmdIndex(rest[0]);
case "search":
return cmdSearch(rest);
default:
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] <yes|no> | index [path] | search <query...>");
}
}
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));

View File

@ -133,6 +133,8 @@ lookup_default() {
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;;
pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks
founder_resources) echo "true" ;; # office-hours resource pitch — #538 permanent opt-out sets false
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines,
@ -319,6 +321,14 @@ case "${1:-}" in
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
VALUE="false"
fi
if [ "$KEY" = "pair_agent" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then
echo "Warning: pair_agent '$VALUE' not recognized. Valid values: on, off. Using off." >&2
VALUE="off"
fi
if [ "$KEY" = "founder_resources" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
echo "Warning: founder_resources '$VALUE' not recognized. Valid values: true, false. Using true." >&2
VALUE="true"
fi
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
VALUE="prompt"

View File

@ -2,6 +2,7 @@
// gstack-context-bill — token bill-of-materials for an installed skills tree.
// All behavior lives in lib/context-bill.ts; this is the CLI shim.
import '../lib/conductor-env-shim'; // --exact needs GSTACK_ANTHROPIC_API_KEY promotion inside Conductor
import { contextBillMain } from '../lib/context-bill';
process.exit(await contextBillMain(process.argv.slice(2)));

View File

@ -51,39 +51,64 @@ if (args.includes("--compact")) {
process.exit(0);
}
// The payload is identified by its leading `{`, not by "first non-flag arg" — a
// `--supersede <id> '{...}'` call would otherwise mistake the target id for the payload.
const jsonArg = args.find((a) => a.trimStart().startsWith("{"));
/** Parse + validate a decision payload. Exits 1 (nothing persisted) when it's bad. */
function validPayload(raw: string): DecisionEvent {
let obj: Partial<DecisionEvent>;
try {
obj = JSON.parse(raw);
} catch {
process.stderr.write("gstack-decision-log: invalid JSON\n");
process.exit(1);
}
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
const res = validateDecide(obj);
if (!res.ok) {
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
process.exit(1);
}
return res.event;
}
const supersedeId = flagValue(args, "--supersede");
const redactId = flagValue(args, "--redact");
if (supersedeId || redactId) {
const kind = supersedeId ? "supersede" : "redact";
const targetId = (supersedeId || redactId) as string;
if (targetId.trimStart().startsWith("{")) {
process.stderr.write(`gstack-decision-log: --${kind} needs the target decision id before the replacement JSON\n`);
process.exit(1);
}
if (kind === "redact" && jsonArg) {
process.stderr.write(
"gstack-decision-log: --redact expunges and takes no replacement; log the replacement in its own call so it isn't dropped\n",
);
process.exit(1);
}
// Validate the replacement BEFORE anything is written, then append it FIRST and
// retire the old one SECOND. Appends are individually atomic, so the only visible
// interleaving is "both active" (recoverable); the reverse order could retire the
// old decision and lose the replacement the user was recording.
const replacement = jsonArg ? { ...validPayload(jsonArg), supersedes: targetId } : undefined;
if (replacement) appendEvent(paths, replacement);
appendEvent(paths, makeRefEvent(kind, targetId, { source: "agent" }));
rebuildSnapshot(paths);
enqueue();
console.log(`${kind}: ${targetId}`);
console.log(replacement ? `${kind}: ${targetId} -> ${replacement.id}` : `${kind}: ${targetId}`);
process.exit(0);
}
const jsonArg = args.find((a) => !a.startsWith("--"));
if (!jsonArg) {
process.stderr.write(
"gstack-decision-log: provide a JSON decision, or --supersede/--redact <id>, or --compact\n",
);
process.exit(1);
}
let obj: Partial<DecisionEvent>;
try {
obj = JSON.parse(jsonArg);
} catch {
process.stderr.write("gstack-decision-log: invalid JSON\n");
process.exit(1);
}
if (obj.scope === "branch" && !obj.branch) obj.branch = gitBranch();
const res = validateDecide(obj);
if (!res.ok) {
process.stderr.write(`gstack-decision-log: ${res.error}\n`);
process.exit(1);
}
appendEvent(paths, res.event);
const event = validPayload(jsonArg);
appendEvent(paths, event);
rebuildSnapshot(paths);
enqueue();
console.log(res.event.id);
console.log(event.id);

View File

@ -4,7 +4,15 @@
# Or: gstack-diff-scope main → prints SCOPE_*=... lines
set -euo pipefail
BASE="${1:-main}"
# Detect the repo's default branch when no arg is given (#703-class
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
_default_base() {
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
echo "main"
}
BASE="${1:-$(_default_base)}"
# Get changed file list
FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "")

View File

@ -197,8 +197,13 @@ RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \
const INPUT_PER_TOKEN = 1e-6;
const OUTPUT_PER_TOKEN = 5e-6;
// Host-neutral model resolution (mirrors lib/eval-model.ts — this inline
// bun -e script cannot import repo-relative libs from an arbitrary cwd).
const distillModel = process.env.GSTACK_EVAL_MODEL_DISTILL
|| process.env.GSTACK_EVAL_MODEL
|| "claude-haiku-4-5-20251001";
const resp = await client.messages.create({
model: "claude-haiku-4-5-20251001",
model: distillModel,
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});

View File

@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
import { writeReceipt } from "../lib/egress-receipt";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
// ── Types ──────────────────────────────────────────────────────────────────
@ -778,6 +779,40 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
}
/**
* Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through
* the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and
* schema migration do not reimplement either here).
*
* The tier was previously enforced only in /sync-gbrain skill prose, so a
* direct or cron invocation of this script ingested repo code regardless of
* a `deny`/`read-only` setting and the egress receipt below cited this
* chokepoint as consent before it existed (#2140 sync path). This check
* closes both gaps.
*
* Fail-open ONLY when no policy store exists (nothing was ever set same
* behavior as before for every non-policy user, and skips the subprocess).
* Fail-closed ("error") when a store exists but can't be read: a policy the
* user set must not be silently bypassed by a broken store or missing jq.
*
* Reads through the shared lib/gbrain-repo-policy-client.ts (same client as
* the code-intelligence consent veto the two gates can never drift, and
* win32 gets the invoke-via-bash path). A spawn failure is still fail-closed
* but says so, instead of the misleading "store could not be read".
*/
export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" {
const res = sharedRepoPolicyTier(url, process.env);
if (res.error === "spawn-failed") {
process.stderr.write(
"[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " +
"refusing ingest rather than bypassing a possibly-set policy\n",
);
return "error";
}
if (res.error) return "error";
return res.tier === "none" ? "unset" : res.tier;
}
async function runCodeImport(args: CliArgs): Promise<StageResult> {
const t0 = Date.now();
const root = repoRoot();
@ -787,6 +822,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
const sourceId = deriveCodeSourceId(root);
// Per-repo trust tier — checked BEFORE the dry-run branch so previews report
// the refusal honestly instead of claiming they would sync.
const policyUrl = originUrl();
const tier = repoPolicyTier(policyUrl);
if (tier === "read-only") {
// Honoring an explicit user setting (search allowed, page writes never) is
// a clean skip, not a stage failure — code ingest writes pages.
return {
name: "code",
ran: false,
ok: true,
duration_ms: Date.now() - t0,
summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`,
detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" },
};
}
if (tier === "deny" || tier === "error") {
const why = tier === "deny"
? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`
: "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy";
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `refused: ${why}`,
detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" },
};
}
// dry-run preview always shows the would-do steps, regardless of local
// engine state. Useful for "what would /sync-gbrain do" without probing
// the engine.

View File

@ -52,8 +52,9 @@ import {
readSync,
closeSync,
rmSync,
realpathSync,
} from "fs";
import { join, basename, dirname } from "path";
import { join, basename, dirname, delimiter } from "path";
import { execFileSync, spawnSync, spawn, type ChildProcess } from "child_process";
import { homedir } from "os";
import { createHash } from "crypto";
@ -1458,13 +1459,40 @@ function runGbrainImportOnce(
// still reporting `written: N` from the staged count. Silent data loss
// on every run. A working run logs `import.collect_files done ... files=N`
// with N > 0 and takes minutes, not seconds.
const child = spawnGbrainAsync([
"import",
stagingDir,
"--no-embed",
...(includeGitignored ? ["--include-gitignored"] : []),
"--json",
]);
//
// GIT_CEILING_DIRECTORIES is the second layer of the same #2144 defense:
// it stops git's upward repo discovery at the staging dir's parent, so a
// git-enumerating collector fails cleanly out of the git fast path and
// falls back to its plain FS walk even on gbrain builds whose flag
// semantics drift. The ceiling must be the REAL path — git compares
// canonicalized directories during discovery, and a staging dir reached
// through a symlink (macOS /var -> /private/var, symlinked $GSTACK_HOME)
// otherwise never matches the ceiling entry. Scoped to this one child;
// no on-disk state, staging-guard/resume contracts untouched.
let ceiling: string;
try {
ceiling = realpathSync(dirname(stagingDir));
} catch {
ceiling = dirname(stagingDir); // staging parent vanished mid-run; spawn will fail loudly anyway
}
const baseEnv: NodeJS.ProcessEnv = {
...process.env,
// path.delimiter, not ':' — git splits this on ';' on Windows, and
// drive-letter paths contain ':' themselves.
GIT_CEILING_DIRECTORIES: process.env.GIT_CEILING_DIRECTORIES
? `${ceiling}${delimiter}${process.env.GIT_CEILING_DIRECTORIES}`
: ceiling,
};
const child = spawnGbrainAsync(
[
"import",
stagingDir,
"--no-embed",
...(includeGitignored ? ["--include-gitignored"] : []),
"--json",
],
{ baseEnv },
);
_activeImportChild = child;
let stdout = "";
let stderr = "";
@ -1941,6 +1969,18 @@ async function ingestPass(args: CliArgs): Promise<BulkResult> {
: ""),
);
}
// Silent-zero pathology detector (#2144's other half): pages were staged
// but NOTHING imported or skipped-as-unchanged. That shape hid the dead
// ingest for months — it must be loud even under --quiet, because a run
// that indexes nothing is otherwise indistinguishable from a healthy one.
const importedCount = (importJson.imported ?? 0) + (importJson.skipped ?? 0);
if (prep.prepared.length > 0 && importedCount === 0 && (importJson.errors ?? 0) === 0) {
console.error(
`[memory-ingest] WARNING: ${prep.prepared.length} page(s) staged but gbrain collected ZERO ` +
`(no imports, no unchanged-skips, no errors). This is the #2144 silent-zero shape — ` +
`check gbrain's import.collect_files log line and your gbrain version.`,
);
}
} finally {
// #1802 D1: in remote-http mode `stagingDir` is the PERSISTENT transcript
// dir (makePersistentTranscriptDir, under ~/.gstack/transcripts/) that

View File

@ -405,7 +405,32 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string;
else if (a === "-h" || a === "--help") help = true;
}
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
if (!base) base = "main";
if (!base) {
// Detect the default branch instead of assuming main (local-only repos
// on trunk/master work like GitHub repos on main). Same probe order as
// the canonical chain in bin/gstack-diff-scope and {{BASE_BRANCH_DETECT}}
// (scripts/resolvers/utility.ts): origin/HEAD -> origin/main ->
// origin/master -> literal "main". origin/HEAD is unset on plain clones
// that never ran `git remote set-head`, so the rev-parse probes matter.
try {
const head = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
base = head.replace("refs/remotes/origin/", "");
} catch {
// fall through to the rev-parse probes
}
if (!base) {
for (const candidate of ["main", "master"]) {
try {
execFileSync("git", ["rev-parse", "--verify", "-q", `origin/${candidate}`], { stdio: ["ignore", "ignore", "ignore"] });
base = candidate;
break;
} catch {
// probe failed; try the next candidate
}
}
}
if (!base) base = "main";
}
if (!bump) {
console.error("Error: --bump is required (major|minor|patch|micro)");
process.exit(2);

View File

@ -61,6 +61,12 @@ elif [ -n "${TMP:-}" ]; then
else
_tmp_root=".gstack/tmp"
fi
# macOS exports TMPDIR with a trailing slash; mktemp templates built as
# "$TMP_ROOT/name-XXXXXX" would then carry "//", and any consumer comparing
# paths gets a spurious mismatch. Strip it (never strips a bare "/"). #2091
case "$_tmp_root" in
*/) [ "$_tmp_root" != "/" ] && _tmp_root="${_tmp_root%/}" ;;
esac
# Strip any trailing slash so consumers can safely concatenate "$TMP_ROOT/name"
# without producing a double slash. On macOS $TMPDIR ends in `/` by default

View File

@ -210,6 +210,13 @@ fi
STEP_FIELD="null"
[ -n "$FAILED_STEP" ] && STEP_FIELD="\"$(json_safe "$FAILED_STEP")\""
# Integrity first: a non-numeric duration would splice raw text into the
# JSON line ("duration_s":%s) and corrupt the whole JSONL stream — the range
# caps below silently no-op on non-integers because both test(1) comparisons
# fail. Reject anything that isn't a plain integer.
case "$DURATION" in
''|*[!0-9]*) DURATION="" ;;
esac
# Cap unreasonable durations
if [ -n "$DURATION" ] && [ "$DURATION" -gt 86400 ] 2>/dev/null; then
DURATION="" # null if > 24h

View File

@ -81,15 +81,27 @@ while IFS= read -r LINE; do
[ -z "$LINE" ] && continue
echo "$LINE" | grep -q '^{' || continue
# Strip local-only fields (keep v, ts, sessions as-is for edge function)
CLEAN="$(echo "$LINE" | sed \
-e 's/,"_repo_slug":"[^"]*"//g' \
-e 's/,"_branch":"[^"]*"//g' \
-e 's/,"repo":"[^"]*"//g')"
# If anonymous tier, strip installation_id
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
# Strip local-only fields (keep v, ts, sessions as-is for edge function).
# jq del() is structural — a value containing an escaped quote (repo names,
# branch names) can't smuggle the field past a regex or corrupt the strip.
# The sed path stays only as a jq-less fallback.
if command -v jq >/dev/null 2>&1; then
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo, .installation_id)' 2>/dev/null)" || CLEAN=""
else
CLEAN="$(printf '%s' "$LINE" | jq -c 'del(._repo_slug, ._branch, .repo)' 2>/dev/null)" || CLEAN=""
fi
# A line jq can't parse is malformed telemetry — drop it rather than
# forwarding bytes the strip never touched.
[ -z "$CLEAN" ] && continue
else
CLEAN="$(echo "$LINE" | sed \
-e 's/,"_repo_slug":"[^"]*"//g' \
-e 's/,"_branch":"[^"]*"//g' \
-e 's/,"repo":"[^"]*"//g')"
if [ "$TIER" = "anonymous" ]; then
CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')"
fi
fi
if [ "$FIRST" = "true" ]; then

214
bin/gstack-verify-gate Executable file
View File

@ -0,0 +1,214 @@
#!/usr/bin/env bash
# gstack-verify-gate — Stop hook. Blocks the turn from ending until the
# project's declared verification command passes.
#
# Declare the command on one line in the project's CLAUDE.md:
# <!-- gstack:verify: bun test -->
#
# Read-or-ask: gstack never invents this command. No declaration, no gate.
# Fails open on every absence (no CLAUDE.md, no declaration, empty value).
#
# Trust boundary: hooks bypass the permission system, so a declared command
# NEVER runs until the user records it in the per-repo trust store:
# gstack-verify-gate --trust (run from inside the repo)
# The store maps realpath(repo root) -> sha256(command) at
# ${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust (flat "path<TAB>hash",
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
# until --trust is run again. Untrusted commands never block the turn.
#
# Exit 0 = allow the turn to end, one-line reason on stdout.
# Exit 2 = block, Claude Code feeds stderr back to the agent.
#
# Remove with: gstack-settings-hook remove-source --source verify-gate
set -uo pipefail
TAB="$(printf '\t')"
STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust"
_sha256() {
if command -v shasum >/dev/null 2>&1; then
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
elif command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$1" | sha256sum | cut -d' ' -f1
else
printf '%s' "$1" | openssl dgst -sha256 | awk '{print $NF}'
fi
}
# Resolve the project root: CLAUDE_PROJECT_DIR, else walk up from $PWD to
# the first directory containing CLAUDE.md. Sets ROOT (may lack CLAUDE.md).
_resolve_root() {
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do
ROOT="$(dirname "$ROOT")"
done
}
# Extract the declared command from $ROOT/CLAUDE.md into CMD (may be empty).
# Accepts both `<!-- gstack:verify: cmd -->` and bare `gstack:verify: cmd`.
_extract_cmd() {
CMD="$(sed -n 's/^[[:space:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
CMD="${CMD%%-->*}"
CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')"
}
# Symlink-stable store key for the root.
_trust_key() {
(cd "$ROOT" 2>/dev/null && pwd -P) || printf '%s' "$ROOT"
}
# Print the stored hash for key $1, or return 1 when absent.
_trusted_hash() {
[ -f "$STORE" ] || return 1
local p h
while IFS="$TAB" read -r p h; do
if [ "$p" = "$1" ]; then
printf '%s' "$h"
return 0
fi
done <"$STORE"
return 1
}
# Record key $1 -> hash $2, replacing any prior entry. Atomic, 0600.
_record_trust() {
local store_dir tmp p h
store_dir="$(dirname "$STORE")"
mkdir -p "$store_dir"
tmp="$STORE.tmp.$$"
: >"$tmp"
chmod 600 "$tmp"
if [ -f "$STORE" ]; then
while IFS="$TAB" read -r p h; do
[ "$p" = "$1" ] || printf '%s\t%s\n' "$p" "$h" >>"$tmp"
done <"$STORE"
fi
printf '%s\t%s\n' "$1" "$2" >>"$tmp"
mv -f "$tmp" "$STORE"
}
# Minimal JSON string escaping (backslash + double quote). CMD and paths are
# single-line by construction, so control characters never appear.
_json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
# Forensic audit of trust grants. --trust stays agent-runnable (guardrail
# posture: catch accidents, not determined actors — same as the redaction
# guard), but a grant is never invisible: append {ts, root, cmd_sha256,
# cmd verbatim, tty} to a 0600 JSONL under GSTACK_HOME/security/.
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
_log_trust_grant() {
local sec_dir log tty ts
sec_dir="${GSTACK_HOME:-$HOME/.gstack}/security"
log="$sec_dir/verify-gate-trust-grants.jsonl"
tty=false
[ -t 0 ] && tty=true
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$sec_dir"
[ -f "$log" ] || : >"$log"
chmod 600 "$log" 2>/dev/null || true
printf '{"ts":"%s","root":"%s","cmd_sha256":"%s","cmd":"%s","tty":%s}\n' \
"$ts" "$(_json_escape "$1")" "$2" "$(_json_escape "$3")" "$tty" >>"$log"
}
if [ "${1:-}" = "--trust" ]; then
_resolve_root
if [ ! -f "$ROOT/CLAUDE.md" ]; then
echo "verify-gate: no CLAUDE.md above $PWD, nothing to trust." >&2
exit 1
fi
_extract_cmd
if [ -z "$CMD" ]; then
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, nothing to trust." >&2
exit 1
fi
KEY="$(_trust_key)"
HASH="$(_sha256 "$CMD")"
_record_trust "$KEY" "$HASH"
_log_trust_grant "$KEY" "$HASH" "$CMD"
echo "verify-gate: trusted '$CMD' for $ROOT."
exit 0
fi
INPUT=""
[ -t 0 ] || INPUT="$(cat)"
# Claude Code re-runs Stop hooks after a block (stop_hook_active=true). A
# re-entry is NOT a free pass: the gate re-runs the trusted check so an agent
# can't clear a red verification by simply stopping again. Re-entry blocks are
# bounded per episode (MAX_REENTRY_BLOCKS) so a stuck check can't loop forever;
# at the bound the gate allows with a loud warning.
REENTRY=0
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
REENTRY=1
fi
_resolve_root
if [ ! -f "$ROOT/CLAUDE.md" ]; then
echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing."
exit 0
fi
_extract_cmd
if [ -z "$CMD" ]; then
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing."
exit 0
fi
# Trust gate: never execute a declared command the user has not recorded.
# Applies on re-entry too — untrusted commands keep the exit-0-with-hint path.
if [ "$(_trusted_hash "$(_trust_key)" || true)" != "$(_sha256 "$CMD")" ]; then
echo "verify-gate: found '$CMD' in $ROOT/CLAUDE.md but it is not trusted yet, skipping; enable with: cd $ROOT && $0 --trust" >&2
echo "verify-gate: declared command not trusted, allowing."
exit 0
fi
# Episode-scoped re-entry attempt counter. Keyed by the hook-input session_id
# when present, else ppid+root — stale entries are fine to overwrite.
MAX_REENTRY_BLOCKS=3
_session_key() {
local sid
sid="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
[ -n "$sid" ] || sid="ppid-$PPID"
_sha256 "$sid|$(_trust_key)"
}
ATTEMPTS_DIR="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-attempts"
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
if [ "$REENTRY" -eq 0 ]; then
rm -f "$COUNTER" 2>/dev/null || true
fi
OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)"
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
rm -f "$COUNTER" 2>/dev/null || true
echo "verify-gate: declared check passed ($CMD)."
exit 0
fi
if [ "$REENTRY" -eq 1 ]; then
COUNT="$(cat "$COUNTER" 2>/dev/null || echo 0)"
case "$COUNT" in
''|*[!0-9]*) COUNT=0 ;;
esac
if [ "$COUNT" -ge "$MAX_REENTRY_BLOCKS" ]; then
rm -f "$COUNTER" 2>/dev/null || true
WARN="verify-gate: WARNING — allowing after $MAX_REENTRY_BLOCKS blocked re-entries but the declared check is still FAILING ($CMD). Verification is RED; do not treat this turn as verified."
echo "$WARN"
echo "$WARN" >&2
exit 0
fi
mkdir -p "$ATTEMPTS_DIR"
echo $((COUNT + 1)) >"$COUNTER"
fi
echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2
printf '%s\n' "$OUT" | tail -20 >&2
echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2
exit 2

View File

@ -769,6 +769,28 @@ should route through `browse` — `screenshot --selector` for visual output,
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
One install to pin, one daemon's lifecycle to manage.
## Session Persistence (opt-in)
By default the headless daemon's cookies and tab state die with it — a crash,
version auto-restart, or `browse stop` logs you out of everything (#778).
Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's
environment: the daemon then snapshots cookies + per-tab
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600)
every 30 seconds and at clean shutdown, and restores it on the next launch.
Facts that matter:
- **Default OFF.** Cookies on disk are a real cost; the user opts in.
- **Headless only.** Headed mode's persistent Chromium profile already owns
its state; replaying tabs would clobber the user's window.
- **Never persisted:** loaded HTML and tab ownership — a tampered state file
cannot smuggle content past load-html's checks or forge ownership. Cookies
for localhost, `.internal`, and cloud-metadata addresses are dropped on
restore.
- **Corrupt state** is moved to `session-state.json.corrupt` (kept for
diagnosis) and the daemon boots fresh — persistence can never block a
launch. The boot log says which happened: `Session state restored: N
cookies / M tabs` or `fresh session`.
## User Handoff
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor

View File

@ -216,6 +216,28 @@ should route through `browse` — `screenshot --selector` for visual output,
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
One install to pin, one daemon's lifecycle to manage.
## Session Persistence (opt-in)
By default the headless daemon's cookies and tab state die with it — a crash,
version auto-restart, or `browse stop` logs you out of everything (#778).
Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's
environment: the daemon then snapshots cookies + per-tab
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600)
every 30 seconds and at clean shutdown, and restores it on the next launch.
Facts that matter:
- **Default OFF.** Cookies on disk are a real cost; the user opts in.
- **Headless only.** Headed mode's persistent Chromium profile already owns
its state; replaying tabs would clobber the user's window.
- **Never persisted:** loaded HTML and tab ownership — a tampered state file
cannot smuggle content past load-html's checks or forge ownership. Cookies
for localhost, `.internal`, and cloud-metadata addresses are dropped on
restore.
- **Corrupt state** is moved to `session-state.json.corrupt` (kept for
diagnosis) and the daemon boots fresh — persistence can never block a
launch. The boot log says which happened: `Session state restored: N
cookies / M tabs` or `fresh session`.
## User Handoff
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor

View File

@ -73,6 +73,82 @@ export function shouldEnableChromiumSandbox(): boolean {
return !(process.env.CI || process.env.CONTAINER || isRoot);
}
/**
* Thrown by probePoisonedChromiumBundle() when it finds and removes a
* Chromium bundle poisoned by the pre-v1.64 in-place rebrand (#2242).
* Call sites rethrow on `instanceof` (never message-string sniffing) so the
* actionable remediation reaches the user instead of being swallowed by the
* probe's fall-through-on-failure catch.
*/
export class PoisonedBundleError extends Error {
constructor(message: string) {
super(message);
this.name = 'PoisonedBundleError';
}
}
/**
* Self-heal probe for bundles the OLD (pre-v1.64) rebrand code already
* poisoned (#2242): the mutation lives in the SHARED Playwright cache, so
* deleting the rebrand code fixes fresh installs only, and the documented
* deploy paths never run upgrade migrations. Detect the mutated plist and
* remove the bundle so the next `playwright install chromium` (or the
* upgrade migration) re-fetches a clean one.
*
* Removal scope: when the .app sits in the standard Playwright cache layout
* (chromium-<rev>/chrome-mac/<name>.app), the WHOLE chromium-<rev> revision
* dir is removed Playwright's INSTALLATION_COMPLETE marker lives there,
* and `playwright install chromium` treats its presence as "is already
* downloaded", so removing only the .app would turn our own remediation
* command into a no-op that leaves the user with no browser at all. Outside
* that layout, the .app plus any sibling INSTALLATION_COMPLETE /
* DEPENDENCIES_VALIDATED markers are removed.
*
* Caller contract: pass ONLY Playwright-cache executables
* (chromium.executablePath()). A bundle supplied via GSTACK_CHROMIUM_PATH
* belongs to the wrapper/embedder its plist legitimately says "GStack
* Browser" and must never be deleted. Both call sites (launchHeaded and
* handoff) honor this, and as a second belt the probe refuses to act on the
* GSTACK_CHROMIUM_PATH executable itself.
*
* @param chromiumExecutablePath the Chromium binary inside the .app
* (/<name>.app/Contents/MacOS/<name>), as returned by
* chromium.executablePath().
* @throws PoisonedBundleError after removing a poisoned bundle the
* message carries the re-fetch command for the user.
*/
export function probePoisonedChromiumBundle(chromiumExecutablePath: string): void {
const fs = require('fs');
const path = require('path');
// Belt to the caller contract: never act on the custom/embedder bundle.
const customPath = process.env.GSTACK_CHROMIUM_PATH;
if (customPath && path.resolve(chromiumExecutablePath) === path.resolve(customPath)) {
return;
}
const chromeContentsDir = path.resolve(path.dirname(chromiumExecutablePath), '..');
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
if (!fs.existsSync(chromePlist)) return;
if (!fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) return;
const appDir = path.resolve(chromeContentsDir, '..');
const revisionDir = path.resolve(appDir, '..', '..');
if (/^chromium-\d+$/.test(path.basename(revisionDir))) {
fs.rmSync(revisionDir, { recursive: true, force: true });
} else {
fs.rmSync(appDir, { recursive: true, force: true });
for (const marker of ['INSTALLATION_COMPLETE', 'DEPENDENCIES_VALIDATED']) {
fs.rmSync(path.join(path.dirname(appDir), marker), { force: true });
}
}
throw new PoisonedBundleError(
'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' +
'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' +
'Re-fetch a clean one with: bunx playwright install chromium — then retry.',
);
}
/**
* Resolve why the underlying Chromium ChildProcess is going away.
*
@ -199,6 +275,15 @@ export class BrowserManager {
// ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched';
/**
* Fired when a RUNNING daemon is promoted to headed mode (see handoff()),
* as opposed to starting headed. The server uses it to cancel the
* parent-process watchdog, which was registered on the assumption that mode
* is fixed at boot and would otherwise kill the freshly handed-off browser
* the next time the spawning shell exits.
*/
onHeadedPromotion?: () => void;
private intentionalDisconnect = false;
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
@ -504,46 +589,32 @@ export class BrowserManager {
// Used by GStack Browser.app to point at the bundled Chromium.
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
// Patch the Chromium .app's Info.plist so macOS shows our name.
// This works for both dev mode (system Playwright cache) and .app bundle.
const chromePath = executablePath || chromium.executablePath();
try {
// Walk up from binary to the .app's Info.plist
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
// → .../Google Chrome for Testing.app/Contents/Info.plist
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
if (fs.existsSync(chromePlist)) {
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
if (plistContent.includes('Google Chrome for Testing')) {
const patched = plistContent
.replace(/Google Chrome for Testing/g, 'GStack Browser');
fs.writeFileSync(chromePlist, patched);
}
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
const iconCandidates = [
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
];
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
if (iconSrc) {
const chromeResources = path.join(chromeContentsDir, 'Resources');
// Read original icon name from plist
const iconMatch = plistContent.match(/<key>CFBundleIconFile<\/key>\s*<string>([^<]+)<\/string>/);
let origIcon = iconMatch ? iconMatch[1] : 'app';
if (!origIcon.endsWith('.icns')) origIcon += '.icns';
const destIcon = path.join(chromeResources, origIcon);
try {
fs.copyFileSync(iconSrc, destIcon);
} catch (err: any) {
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
}
}
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
// replace) and overwrote its Resources/*.icns is deliberately GONE.
// Chrome for Testing is a code-signed bundle: the global replace renamed
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
// writes broke the codesign seal — GPU process exit_code=5, headed mode
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
// of the signed bundle. Do not reintroduce writes into the Chromium
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
// you do.
//
// Self-heal for bundles the OLD code already poisoned: probe the
// Playwright-cache bundle and remove it when the mutated plist is
// present (see probePoisonedChromiumBundle for the removal-scope
// rationale). Scoped to the Playwright cache copy — a
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
// never probed.
if (!executablePath) {
try {
probePoisonedChromiumBundle(chromium.executablePath());
} catch (err: unknown) {
if (err instanceof PoisonedBundleError) throw err;
// Probe failures (no bundle yet, EACCES) fall through to launch,
// which produces its own actionable error.
}
} catch (err: any) {
// Non-fatal: app name stays as Chrome for Testing (ENOENT/EACCES expected)
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
}
// Build custom user agent: report as stock Chrome with the version
@ -1607,6 +1678,20 @@ export class BrowserManager {
fs.mkdirSync(userDataDir, { recursive: true });
cleanSingletonLocks(userDataDir);
// Self-heal probe (#2242): handoff always launches the Playwright-cache
// bundle (this launchPersistentContext call passes no executablePath),
// so a bundle poisoned by the old in-place rebrand would GPU-crash here
// exactly like launchHeaded(). Same probe, same contract: a
// GSTACK_CHROMIUM_PATH bundle is never passed in. The rethrown typed
// error surfaces through the outer catch as the actionable
// "Cannot open headed browser" message, headless browser untouched.
try {
probePoisonedChromiumBundle(chromium.executablePath());
} catch (err: unknown) {
if (err instanceof PoisonedBundleError) throw err;
// Probe failures (no bundle yet, EACCES) fall through to launch.
}
// T1: same automation-tell-stripping defaults as launchHeaded().
// The handoff path (headless → headed re-launch) takes the same
// anti-detection posture.
@ -1639,6 +1724,14 @@ export class BrowserManager {
this.tabSessions.clear();
this.connectionMode = 'headed';
// Promotion, not a headed boot. The server registered a parent-process
// watchdog because this daemon started headless, and that watchdog kills
// headed daemons when their parent exits — which for a CLI-spawned daemon
// is immediately. Without this the handed-off browser dies ~15s later,
// taking whatever the user was mid-way through (a login, an MFA prompt)
// with it.
this.onHeadedPromotion?.();
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
// restoreState() navigates so the init scripts apply to the restored
// pages — without this the handed-off browser had cmdline args but no

View File

@ -77,7 +77,8 @@ globalThis.Bun = {
cwd: options.cwd,
// Node defaults windowsHide to false; Bun.spawn hides the console
// window. Without this the shim silently inverts the behavior on the
// one platform it exists to serve. See the spawn() note below.
// one platform it exists to serve — every console child pops a window.
// Forwarded (not hardcoded) so an explicit windowsHide:false survives.
windowsHide: options.windowsHide !== false,
});
@ -97,108 +98,17 @@ globalThis.Bun = {
cwd: options.cwd,
// stdio:'ignore' silences a child's output but does not suppress its
// console window on Windows. The terminal-agent respawn (server.ts
// watchdog, 60s ticker) therefore popped a visible bun.exe window on
// every respawn until this was forwarded.
// watchdog, 60s ticker) popped a visible bun.exe window on every
// respawn until this was forwarded. Forwarded, not hardcoded, so an
// explicit windowsHide:false survives.
windowsHide: options.windowsHide !== false,
});
// Drain stdout/stderr eagerly into in-memory buffers. Bun's spawn buffers
// these for the consumer; Node's Readables are pull-based, so if the caller
// awaits `proc.exited` before reading, anything past the OS pipe buffer
// (~16-64 KB) back-pressures the child until it blocks in write() and
// `exit` never fires. Eager draining keeps the pipes flowing regardless
// of read order; replay below is via fresh Web ReadableStreams.
//
// Cap the buffer so a runaway child can't OOM the server. 16 MB is
// generous: DPAPI outputs are tiny, tasklist is <1 KB, and the
// browser-skill consumer has its own 1 MB readCapped. Once the cap is
// reached we keep draining the pipe (so the child never blocks) but
// discard further bytes. Override via GSTACK_SPAWN_MAX_BUFFER (bytes).
const MAX_BUFFER = Math.max(
0,
parseInt(process.env.GSTACK_SPAWN_MAX_BUFFER || '', 10) || 16 * 1024 * 1024,
);
const drain = (stream) => {
if (!stream) return { done: Promise.resolve(), chunks: [], truncated: false };
const state = { chunks: [], bytes: 0, truncated: false };
const done = new Promise((resolve) => {
stream.on('data', (chunk) => {
if (state.bytes >= MAX_BUFFER) { state.truncated = true; return; }
if (state.bytes + chunk.length <= MAX_BUFFER) {
state.chunks.push(chunk);
state.bytes += chunk.length;
} else {
const remaining = MAX_BUFFER - state.bytes;
state.chunks.push(chunk.subarray(0, remaining));
state.bytes = MAX_BUFFER;
state.truncated = true;
}
});
// Any terminal event resolves: 'end' on normal close, 'error' on a
// stream-level error, 'close' as the belt-and-suspenders for spawn
// failures where Node fires 'close' but neither 'end' nor 'error'.
stream.once('end', resolve);
stream.once('error', resolve);
stream.once('close', resolve);
});
return { done, chunks: state.chunks };
};
const stdoutDrain = drain(proc.stdout);
const stderrDrain = drain(proc.stderr);
// Bun's spawn exposes `proc.exited` as a Promise resolving to the exit
// code; several call sites — DPAPI decryption, isBrowserRunning,
// browser-skill-commands — `await proc.exited` directly or via
// Promise.race with a timeout. Without this, those awaits resolve to
// `undefined` immediately and the operation looks like a silent failure.
// Resolve only after both pipes have finished draining so consumers that
// read stdout AFTER awaiting exit see the full output, not a partial buffer.
const exited = new Promise((resolveExited) => {
let exitStatus;
proc.once('exit', (code, signal) => {
// Match Bun: exit code on normal exit; 128 + signal number on signal;
// 0 if neither was reported.
if (code !== null) exitStatus = code;
else if (signal) exitStatus = 128 + (require('os').constants.signals[signal] || 0);
else exitStatus = 0;
});
proc.once('error', () => {
if (exitStatus === undefined) exitStatus = 1;
});
// Wait for either 'exit' (normal child lifecycle) or 'error' (spawn
// failure — Node fires error without exit when the binary is missing).
// Either path resolves the lifecycle promise; without listening to both
// a spawn error hangs `await proc.exited` until the consumer's own
// timeout fires.
const lifecycle = new Promise((r) => {
proc.once('exit', r);
proc.once('error', r);
});
Promise.all([lifecycle, stdoutDrain.done, stderrDrain.done])
.then(() => resolveExited(exitStatus !== undefined ? exitStatus : 0));
});
// Replay buffered output as a fresh Web ReadableStream. `start()` awaits
// the drain before enqueueing so `new Response(proc.stdout).text()` yields
// the complete output regardless of whether the consumer reads before or
// after awaiting `proc.exited`. Stream is single-shot (locked after one
// read), matching Bun's behavior.
const replay = (d) => new ReadableStream({
async start(controller) {
await d.done;
for (const chunk of d.chunks) {
controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
}
controller.close();
},
});
return {
pid: proc.pid,
stdout: replay(stdoutDrain),
stderr: replay(stderrDrain),
stdout: proc.stdout,
stderr: proc.stderr,
stdin: proc.stdin,
exited,
unref() { proc.unref(); },
kill(signal) { proc.kill(signal); },
};

View File

@ -14,7 +14,7 @@ import * as path from 'path';
import { spawn as nodeSpawn } from 'child_process';
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled } from './config';
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
import { redactProxyUrl } from './proxy-redact';
import { spawnTerminalAgent } from './terminal-agent-control';
@ -168,7 +168,7 @@ async function killServer(pid: number): Promise<void> {
try {
Bun.spawnSync(
['taskkill', '/PID', String(pid), '/T', '/F'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000 }
{ stdout: 'pipe', stderr: 'pipe', timeout: 5000, windowsHide: true }
);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
@ -348,9 +348,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
const launcherCode =
`const{spawn}=require('child_process');` +
`spawn(process.execPath,[${JSON.stringify(NODE_SERVER_SCRIPT)}],` +
`{detached:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`{detached:true,windowsHide:true,stdio:['ignore','ignore','ignore'],env:Object.assign({},process.env,` +
`${extraEnvStr})}).unref()`;
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'] });
Bun.spawnSync(['node', '-e', launcherCode], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
} else {
// macOS/Linux: Bun.spawn().unref() only removes the child from Bun's event
// loop — it does NOT call setsid(), so the spawned server stays in the
@ -365,6 +365,7 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// the Windows path's rationale — same root cause, different OS API.
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
windowsHide: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_PARENT_PID: parentPid, ...extraEnv },
}).unref();
@ -408,31 +409,29 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
}
function errorCode(err: unknown): string {
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code?: unknown }).code;
if (typeof code === 'string' && code.length > 0) return code;
export class ServerLockError extends Error {
code: string;
constructor(code: string, lockPath: string, cause: string) {
super(`E_SERVER_LOCK (${code}): cannot acquire ${lockPath}${cause}`);
this.name = 'ServerLockError';
this.code = code;
}
return 'UNKNOWN';
}
function errorMessage(err: unknown): string {
if (err && typeof err === 'object' && 'message' in err) {
const message = (err as { message?: unknown }).message;
if (typeof message === 'string' && message.length > 0) return message;
}
return String(err);
}
function logServerLockError(action: string, lockPath: string, err: unknown): void {
console.error(`[browse] acquireServerLock: unexpected ${errorCode(err)} while ${action} ${lockPath}: ${errorMessage(err)}`);
}
/**
* Acquire an exclusive lockfile to prevent concurrent ensureServer() races (TOCTOU).
* Returns a cleanup function that releases the lock.
* Returns a cleanup function that releases the lock, or null when another
* LIVE process genuinely holds the lock (real contention).
*
* Error honesty (#1084): only EEXIST is contention. ENOENT (state dir
* missing) self-heals with one mkdir retry; every other errno (EACCES,
* ENOSPC, ...) throws ServerLockError with the real errno instead of
* reporting phantom "another process holds the lock" contention forever.
*/
export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`): (() => void) | null {
export function acquireServerLock(
lockPath: string = `${config.stateFile}.lock`,
depth = 0,
): (() => void) | null {
try {
// 'wx' — create exclusively, fails if file already exists (atomic check-and-create)
// Using string flag instead of numeric constants for Bun Windows compatibility
@ -440,36 +439,35 @@ export function acquireServerLock(lockPath: string = `${config.stateFile}.lock`)
fs.writeSync(fd, `${process.pid}\n`);
fs.closeSync(fd);
return () => { safeUnlink(lockPath); };
} catch (err) {
if (errorCode(err) !== 'EEXIST') {
logServerLockError('opening', lockPath, err);
return null;
} catch (err: any) {
if (err?.code === 'ENOENT') {
// Lock dir missing — create it and retry once.
if (depth >= 1) throw new ServerLockError('ENOENT', lockPath, 'lock directory could not be created');
mkdirSecure(path.dirname(lockPath));
return acquireServerLock(lockPath, depth + 1);
}
// Lock already held — check if the holder is still alive
let holderPid: number;
if (err?.code !== 'EEXIST') {
throw new ServerLockError(err?.code || 'UNKNOWN', lockPath, err?.message || String(err));
}
// EEXIST — real contention. Check if the holder is still alive.
// Depth cap 5 bounds the stale-lock unlink/retry livelock.
try {
holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
} catch (readErr) {
if (errorCode(readErr) === 'ENOENT') {
return acquireServerLock(lockPath);
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
if (holderPid && isProcessAlive(holderPid)) {
return null; // Another live process holds the lock
}
logServerLockError('reading holder PID from', lockPath, readErr);
return null;
}
if (holderPid && isProcessAlive(holderPid)) {
return null; // Another live process holds the lock
}
// Stale lock — remove and retry
try {
// Stale lock — remove and retry
fs.unlinkSync(lockPath);
} catch (unlinkErr) {
logServerLockError('removing stale', lockPath, unlinkErr);
return null;
if (depth >= 5) return null;
return acquireServerLock(lockPath, depth + 1);
} catch (readErr: any) {
if (readErr?.code === 'ENOENT') {
// Lock vanished between open and read (holder released) — retry.
if (depth >= 5) return null;
return acquireServerLock(lockPath, depth + 1);
}
throw new ServerLockError(readErr?.code || 'UNKNOWN', lockPath, readErr?.message || String(readErr));
}
return acquireServerLock(lockPath);
}
}
@ -656,17 +654,7 @@ async function sendCommand(state: ServerState, command: string, args: string[],
process.exit(1);
}
// Connection error — server may have crashed, OR may just be busy.
// The compiled CLI runs on Bun, whose fetch reports a refused/dropped
// socket as err.code 'ConnectionRefused' / 'ConnectionClosed' (message
// "Unable to connect. Is the computer able to access the url?"), NOT Node's
// ECONNREFUSED/ECONNRESET. Match both, or daemon crashes leak the raw Bun
// error and exit 1 instead of triggering the busy-check/restart below.
const isConnError =
err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
err.code === 'ConnectionRefused' || err.code === 'ConnectionClosed' ||
err.message?.includes('fetch failed') ||
err.message?.includes('Unable to connect');
if (isConnError) {
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
const oldState = readState();
// #1781 busy-vs-dead: a single-threaded daemon under beacon/extension load
// can briefly stop answering HTTP while still alive. Before declaring a
@ -980,8 +968,12 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
if (pairData.tunnel_url) {
serverUrl = pairData.tunnel_url;
} else if (!localHost) {
// No tunnel active. Check if ngrok is available and auto-start.
const ngrokAvailable = isNgrokAvailable();
// No tunnel active. Remote tunneling (pair-agent) is opt-in — never
// auto-start it unless the user explicitly enabled it, even if ngrok is
// installed and authed. First use goes through the /pair-agent skill's
// consent question, which sets the key.
const pairEnabled = isPairAgentEnabled();
const ngrokAvailable = pairEnabled && isNgrokAvailable();
if (ngrokAvailable) {
console.log('[browse] ngrok detected. Starting tunnel...');
try {
@ -1005,6 +997,14 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
console.warn('[browse] Using localhost (same-machine only).\n');
serverUrl = pairData.server_url;
}
} else if (!pairEnabled) {
// Consent gate, not a tooling gap: when pair_agent is off, ngrok
// setup instructions can never fix it. Name the real remedy, with
// the same wording as the /tunnel/start 403 body in server.ts.
console.warn('[browse] No tunnel active: pair-agent is off (tunnel exposes this browser beyond the machine).');
console.warn('[browse] Instructions will use localhost (same-machine only).');
console.warn('[browse] For remote agents: enable once with `gstack-config set pair_agent on` — or run /pair-agent, which asks for consent and sets it.\n');
serverUrl = pairData.server_url;
} else {
console.warn('[browse] No tunnel active and ngrok is not installed/configured.');
console.warn('[browse] Instructions will use localhost (same-machine only).');
@ -1207,7 +1207,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const newPid = spawnTerminalAgent({
stateFile: config.stateFile,
serverPort: newState.port,
ownerPid: newState.pid,
cwd: config.projectDir,
});
if (newPid) {
@ -1300,7 +1299,6 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
spawnTerminalAgent({
stateFile: config.stateFile,
serverPort: respawned.port,
ownerPid: respawned.pid,
cwd: config.projectDir,
});
} catch (err: any) {

View File

@ -187,6 +187,64 @@ export function resolveGstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
}
/**
* Read one key from the flat-YAML config store at <gstack home>/config.yaml
* (the shape bin/gstack-config writes: `key: value` lines). Tolerates
* optional single/double quotes around the value and a trailing `# comment`.
* Returns the unquoted value string, or null when the file is missing or
* unreadable or the key is absent.
*
* Single source of truth for flat-YAML key reads isPairAgentEnabled
* (pair_agent) and telemetry.ts (telemetry tier) both route through it so
* the two consent gates can never drift on parsing semantics.
*/
export function readGstackConfigYamlKey(key: string): string | null {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
try {
const yaml = fs.readFileSync(path.join(resolveGstackHome(), 'config.yaml'), 'utf-8');
// Last match wins: bin/gstack-config's `get` reads duplicates with
// `tail -1`, and both surfaces must agree on the same line.
const all = [...yaml.matchAll(new RegExp(`^\\s*${escaped}\\s*:\\s*['"]?([^'"#\\n]*?)['"]?\\s*(?:#.*)?$`, 'gm'))];
return all.length > 0 ? all[all.length - 1][1] : null;
} catch {
return null;
}
}
/**
* Is the remote pair-agent (ngrok tunnel) surface opt-in enabled?
*
* Fail-closed: the tunnel exposes the local browser to the internet, so it
* stays OFF unless the user explicitly ran `gstack-config set pair_agent on`
* (the /pair-agent skill asks once on first use and sets it). Any read/parse
* failure (missing config, malformed JSON) also resolves OFF. The tunnel
* egress receipts cite this gate as their consent it must exist and gate
* every activation point (#B6, fork port wave 2).
*
* Env override `GSTACK_PAIR_AGENT=on|off` wins (used by tests and as an
* emergency knob), mirroring the telemetry env-hint convention.
*/
export function isPairAgentEnabled(): boolean {
const env = process.env.GSTACK_PAIR_AGENT;
if (env === 'on') return true;
if (env === 'off') return false;
// Canonical store: ~/.gstack/config.yaml (flat `key: value` lines, written
// by bin/gstack-config — which is what the /pair-agent consent step runs).
// The fork read config.json; porting that verbatim would have made the gate
// silently un-enableable on main. JSON kept as a fallback shape only.
// Anything other than exactly on/off (missing key, malformed value) falls
// through to the JSON fallback and ultimately fails closed.
const yamlValue = readGstackConfigYamlKey('pair_agent');
if (yamlValue === 'on') return true;
if (yamlValue === 'off') return false;
try {
const raw = fs.readFileSync(path.join(resolveGstackHome(), 'config.json'), 'utf-8');
return JSON.parse(raw)?.pair_agent === 'on';
} catch {
return false;
}
}
/**
* Resolve the Chromium profile directory.
*

View File

@ -526,6 +526,7 @@ async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
].join('; ');
const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
windowsHide: true,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
@ -778,7 +779,7 @@ function isBrowserRunning(browserName: string): Promise<boolean> {
const exe = browserName.toLowerCase().includes('edge') ? 'msedge.exe' : 'chrome.exe';
return new Promise((resolve) => {
const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${exe}`, '/NH'], {
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe', stderr: 'pipe', windowsHide: true,
});
proc.exited.then(async () => {
const out = await new Response(proc.stdout).text();

View File

@ -7,6 +7,8 @@
import * as fs from 'fs';
const IS_WINDOWS = process.platform === 'win32';
// ─── Filesystem ────────────────────────────────────────────────
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
@ -34,39 +36,23 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
}
}
/**
* Check if a PID is alive. Pure boolean probe never throws.
*
* Signal 0 on every platform. Node and Bun both map `process.kill(pid, 0)` to
* an OpenProcess existence check on Windows, so the POSIX idiom is portable
* here no shell-out needed.
*
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and string-match
* the CSV. That was wrong in two ways, both of which bit in production:
*
* 1. FALSE NEGATIVES UNDER LOAD. `tasklist` takes ~700-1700ms on an idle
* Windows box and far longer under memory pressure. A Bun.spawnSync that
* hits its `timeout` still RETURNS, carrying partial stdout so the
* `.includes()` match came back false and a LIVE process was reported
* dead. Callers (killAgentByRecord, the terminal-agent watchdog) then
* skipped the kill and respawned around the survivor, leaking one
* terminal-agent per watchdog tick. The leak was self-reinforcing: every
* orphan added memory pressure, which made the next tasklist slower,
* which produced the next false negative.
* 2. A VISIBLE CONSOLE WINDOW per probe (no windowsHide), so a background
* watchdog strobed a terminal into the foreground every 60 seconds.
*
* Signal 0 is ~74,000x faster (0.004ms vs 270ms, measured), spawns nothing,
* and cannot time out.
*
* EPERM means the process EXISTS but we lack rights to signal it. That is
* alive; returning false there would reintroduce failure mode 1.
*/
/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */
export function isProcessAlive(pid: number): boolean {
if (IS_WINDOWS) {
try {
const result = Bun.spawnSync(
['tasklist', '/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'],
{ stdout: 'pipe', stderr: 'pipe', timeout: 3000, windowsHide: true }
);
return result.stdout.toString().includes(`"${pid}"`);
} catch {
return false;
}
}
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
return err?.code === 'EPERM';
} catch {
return false;
}
}

View File

@ -117,7 +117,7 @@ export function restrictFilePermissions(filePath: string): void {
execFileSync(
'icacls',
[filePath, '/inheritance:r', '/grant:r', `${user}:(F)`],
{ stdio: 'ignore' },
{ stdio: 'ignore', windowsHide: true },
);
} catch (err) {
warnIcaclsFailure(filePath, err);
@ -147,7 +147,7 @@ export function restrictDirectoryPermissions(dirPath: string): void {
execFileSync(
'icacls',
[dirPath, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)(F)`],
{ stdio: 'ignore' },
{ stdio: 'ignore', windowsHide: true },
);
} catch (err) {
warnIcaclsFailure(dirPath, err);
@ -185,14 +185,54 @@ export function appendSecureFile(
if (!existed) restrictFilePermissions(filePath);
}
/**
* Windows only: probe whether the current process can actually list the
* directory. `fs.accessSync` doesn't consult NTFS ACLs on Windows, so a
* real readdir is the only honest check.
*/
function canListDir(dirPath: string): boolean {
try { fs.readdirSync(dirPath); return true; } catch { return false; }
}
/**
* Windows only: repair a broken DACL on a state directory (#1605).
*
* `icacls /inheritance:r /grant:r <user>:(F)` is a single command, but the
* two halves can partially fail: inheritance gets stripped while the user
* grant doesn't resolve (localized account names, domain accounts, roaming
* profiles). The result is a DACL with no usable ACE often just a machine
* SID and the client can't read its own state files. `/reset` restores
* inherited ACLs from the parent, making the directory functional again.
* Functional-but-unhardened beats hardened-but-unusable.
*/
export function repairBrokenDacl(dirPath: string): void {
if (process.platform !== 'win32') return;
try {
execFileSync('icacls', [dirPath, '/reset', '/T', '/C', '/Q'], { stdio: 'ignore', windowsHide: true });
} catch (err) {
warnIcaclsFailure(dirPath, err);
}
}
/**
* `mkdir -p` with owner-only directory permissions, cross-platform.
* Replaces `fs.mkdirSync(path, { recursive: true, mode: 0o700 })` + Windows ACL.
* Safe to call on an existing directory re-applies the ACL idempotently.
*
* Windows: after applying the restricted ACL, verifies the directory is
* still listable by this process and repairs a broken DACL (#1605) if not.
*/
export function mkdirSecure(dirPath: string): void {
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
restrictDirectoryPermissions(dirPath);
if (process.platform === 'win32' && !canListDir(dirPath)) {
repairBrokenDacl(dirPath);
restrictDirectoryPermissions(dirPath);
// If re-hardening broke access again, reset once more and leave the
// directory with inherited ACLs — the client must be able to read
// its own state.
if (!canListDir(dirPath)) repairBrokenDacl(dirPath);
}
}
/**

View File

@ -20,6 +20,7 @@ import * as path from 'path';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { TEMP_DIR } from './platform';
import { resolveConfig } from './config';
import { filterSessionCookies } from './session-persist';
import type { Frame } from 'playwright';
/** Tokenize a pipe segment respecting double-quoted strings. */
@ -421,25 +422,18 @@ export async function handleMetaCommand(
}
case 'stop': {
// Defer shutdown so the response flushes before process.exit() (same
// reason as 'restart' below). Otherwise the CLI sees a dropped socket;
// and now that connection-loss triggers the crash-retry path, that would
// resurrect a fresh daemon only to stop it again. Send the 200, then exit.
setTimeout(() => { void shutdown(); }, 100);
// Return the acknowledgement before closing the listener. Shutting down
// inline resets the CLI's fetch, which it reasonably interprets as a
// crash and then restarts the daemon it was asked to stop.
setTimeout(() => { void shutdown(); }, 25).unref?.();
return 'Server stopped';
}
case 'restart': {
// Signal that we want a restart — the CLI will detect exit and restart.
// Signal that we want a restart — the CLI will detect exit and restart
console.log('[browse] Restart requested. Exiting for CLI to restart.');
// Defer shutdown one tick so this HTTP response actually flushes before
// process.exit(). shutdown() exits inline (server.ts), so the old
// `await shutdown(); return 'Restarting...'` never sent a response — the
// CLI saw a dropped socket and `browse restart` errored out. The daemon
// now exits ~100ms after the CLI gets its 200; the next browse command
// lazily cold-starts a fresh one.
setTimeout(() => { void shutdown(); }, 100);
return 'Restarting... (daemon exiting; next browse command starts a fresh one)';
setTimeout(() => { void shutdown(); }, 25).unref?.();
return 'Restarting...';
}
// ─── Visual ────────────────────────────────────────
@ -939,15 +933,13 @@ export async function handleMetaCommand(
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) {
throw new Error('Invalid state file: expected cookies and pages arrays');
}
// Validate and filter cookies — reject malformed or internal-network cookies
const validatedCookies = data.cookies.filter((c: any) => {
if (typeof c !== 'object' || !c) return false;
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
if (typeof c.domain !== 'string' || !c.domain) return false;
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
return true;
});
// Validate and filter cookies via the shared hygiene filter in
// session-persist.ts (isInternalCookieDomain): rejects malformed
// cookies and internal-network domains — localhost, *.internal,
// loopback literals (127.x, ::1), and link-local/cloud-metadata
// (169.254.x) — that a tampered state file could use to reach local
// services or the metadata endpoint.
const validatedCookies = filterSessionCookies(data.cookies);
if (validatedCookies.length < data.cookies.length) {
console.warn(`[browse] Filtered ${data.cookies.length - validatedCookies.length} invalid cookies from state file`);
}

View File

@ -35,7 +35,11 @@ import {
isRootToken, checkConnectRateLimit, type TokenInfo,
} from './token-registry';
import { validateTempPath } from './path-security';
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
import {
isSessionPersistEnabled, persistSessionState, restoreSessionState,
sessionPersistIntervalMs, SESSION_STATE_FILE,
} from './session-persist';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { createSseEndpoint } from './sse-helpers';
import { initAuditLog, writeAuditEntry } from './audit';
@ -728,6 +732,12 @@ const idleCheckInterval = setInterval(idleCheckTick, 60_000);
// dual-instance fix` describe block for usage.
export const __testInternals__ = {
idleCheckTick,
// Watchdog seams (watchdog.test.ts): drive the 15s poll against an
// arbitrary (dead) PID, trigger the handoff-promotion suppression exactly
// as onHeadedPromotion does, and reset the latches between tests.
parentWatchdogTick,
suppressHeadedParentShutdown,
resetParentWatchdogState: () => { headedParentShutdownSuppressed = false; parentGone = false; },
setTunnelActive: (v: boolean) => { tunnelActive = v; },
setLastActivity: (t: number) => { lastActivity = t; },
formatExplicitPortUnavailableError,
@ -757,47 +767,83 @@ const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10);
// the closure every 15s. The CLI's connect path sets BROWSE_HEADED=1 + PID=0,
// so this branch is the normal path for /open-gstack-browser.
const IS_HEADED_WATCHDOG = process.env.BROWSE_HEADED === '1';
// Poll interval is env-tunable so the watchdog E2E test can use a ~250ms tick
// instead of waiting out the production 15s interval (was a 20s blind sleep).
// Floor of 50ms guards against a typo'd 0 busy-looping the server.
const WATCHDOG_INTERVAL_MS = (() => {
const raw = parseInt(process.env.BROWSE_WATCHDOG_INTERVAL_MS || '', 10);
return Number.isFinite(raw) && raw >= 50 ? raw : 15_000;
})();
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
let parentGone = false;
setInterval(() => {
try {
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
} catch {
// Parent exited. Resolution order:
// 1. Active cookie picker (one-time code or session live)? Stay alive
// regardless of mode — tearing down the server mid-import leaves the
// picker UI with a stale "Failed to fetch" error.
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
// these modes (see idleCheckInterval above — both early-return), so
// ignoring parent death here would leak orphan daemons after
// /pair-agent or /open-gstack-browser sessions.
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
// the parent shell between invocations. The idle timeout (30 min)
// handles eventual cleanup.
if (hasActivePicker()) return;
const headed = activeBrowserManager.getConnectionMode() === 'headed';
if (headed || tunnelActive) {
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
activeShutdown?.();
} else if (!parentGone) {
parentGone = true;
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`);
}
// Runtime promotion to headed (`handoff`) must NOT clear this interval — the
// same tick is the tunnel-orphan reaper, and idle timeout is disabled in
// tunnel mode, so parent death is the ONLY thing that reaps an
// internet-exposed daemon after handoff → resume → /pair-agent. Promotion
// sets this suppress flag instead; the tick re-reads it (and tunnelActive)
// every pass. See suppressHeadedParentShutdown() below.
let headedParentShutdownSuppressed = false;
// Latch for the one-time "parent exited, staying alive" log line.
let parentGone = false;
// Named + parameterized (default: the boot-time env PID) so watchdog.test.ts
// can drive the tick deterministically via __testInternals__, mirroring
// idleCheckTick above. setInterval invokes it with no args in production.
function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
try {
process.kill(parentPid, 0); // signal 0 = existence check only, no signal sent
} catch {
// Parent exited. Resolution order:
// 1. Active cookie picker (one-time code or session live)? Stay alive
// regardless of mode — tearing down the server mid-import leaves the
// picker UI with a stale "Failed to fetch" error.
// 2. Headed (unless suppressed by a runtime promotion) / tunnel mode?
// Shutdown. The idle timeout doesn't apply in these modes (see
// idleCheckInterval above — both early-return), so ignoring parent
// death here would leak orphan daemons after /pair-agent or
// /open-gstack-browser sessions.
// 3. Normal (headless) mode, or headed-by-promotion? Stay alive. Claude
// Code's Bash tool kills the parent shell between invocations, and a
// promoted daemon's user owns the window lifecycle. The idle timeout
// (30 min) handles eventual cleanup.
if (hasActivePicker()) return;
const headed = activeBrowserManager.getConnectionMode() === 'headed'
&& !headedParentShutdownSuppressed;
if (headed || tunnelActive) {
console.log(`[browse] Parent process ${parentPid} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
activeShutdown?.();
} else if (!parentGone) {
parentGone = true;
console.log(`[browse] Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`);
}
}, WATCHDOG_INTERVAL_MS);
}
}
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
setInterval(parentWatchdogTick, 15_000);
} else if (IS_HEADED_WATCHDOG) {
console.log('[browse] Parent-process watchdog disabled (headed mode)');
} else if (BROWSE_PARENT_PID === 0) {
console.log('[browse] Parent-process watchdog disabled (BROWSE_PARENT_PID=0)');
}
/**
* Suppress the headed-mode parent-death shutdown after a runtime promotion.
*
* The watchdog's contract is "headless daemons outlive their parent, headed ones
* do not" reasonable at boot, when mode is fixed by env. `handoff` breaks that
* assumption: it swaps in a headed context on a RUNNING daemon
* (browser-manager.ts, connectionMode = 'headed') without a restart, so a daemon
* that legitimately registered a watchdog is suddenly on the fatal side of the
* branch. The parent is typically a short-lived shell Claude Code's Bash tool
* kills one after every invocation so the next 15s poll shuts the daemon down,
* discarding whatever the user was handed off to do, such as a login.
*
* Once promoted, the user owns the window lifecycle exactly as if the daemon had
* been started headed, which is the case the env guards already exempt.
*
* A flag, NOT clearInterval: the tick doubles as the tunnel-orphan reaper
* (its `tunnelActive` branch), and idle timeout is disabled in tunnel mode
* clearing the whole interval here left handoff resume /pair-agent with
* an internet-exposed daemon nothing could ever reap. After promotion, parent
* death no longer kills the daemon for BEING HEADED, but still kills it when
* a tunnel is active.
*/
function suppressHeadedParentShutdown(): void {
if (headedParentShutdownSuppressed) return;
headedParentShutdownSuppressed = true;
console.log('[browse] Parent-death headed shutdown suppressed (promoted to headed at runtime); watchdog stays armed as the tunnel-orphan reaper');
}
// ─── Command Sets (from commands.ts — single source of truth) ───
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
@ -840,6 +886,11 @@ function emitInspectorEvent(event: any): void {
// ─── Server ────────────────────────────────────────────────────
const browserManager = new BrowserManager();
// Declared here rather than beside suppressHeadedParentShutdown: that function
// sits with the watchdog it gates, which is above this line, and binding it up
// there would touch `browserManager` in its temporal dead zone — aborting
// module evaluation and leaving every later const uninitialized.
browserManager.onHeadedPromotion = suppressHeadedParentShutdown;
// Indirection for embedders. Module-level handlers (idleCheckTick, parent
// watchdog, SIGTERM) read activeBrowserManager so that buildFetchHandler can
// retarget them at a caller-supplied BrowserManager. Symmetric with the
@ -858,6 +909,11 @@ let activeBrowserManager: BrowserManager = browserManager;
// any buildFetchHandler call rebinds onDisconnect onto the cfg instance.
browserManager.onDisconnect = (code) => activeShutdown?.(code ?? 2);
let isShuttingDown = false;
// Session-persist ticker handle. Registered in start() (module scope so the
// factory's shutdown() can reach it), cleared by shutdown() BEFORE the final
// snapshot — a tick landing during browser teardown would otherwise overwrite
// the good final snapshot with a degraded one (zero tabs).
let sessionPersistInterval: ReturnType<typeof setInterval> | null = null;
type PortCheckResult =
| { available: true }
@ -1699,8 +1755,33 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
clearInterval(flushInterval);
clearInterval(idleCheckInterval);
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
// Stop the session-persist ticker BEFORE the final snapshot below —
// paired with the isShuttingDown gate inside the tick, this guarantees
// no interval snapshot can race the final one during teardown.
if (sessionPersistInterval) {
clearInterval(sessionPersistInterval);
sessionPersistInterval = null;
}
await flushBuffers();
// Final session snapshot before the browser goes away (#778). Best
// effort with a hard 2s deadline: shutdown must never hang on a wedged
// page.evaluate — after the deadline we proceed to browser close and let
// the previous interval snapshot stand (atomic writes guarantee it's
// intact). The .catch is attached to the persist promise itself so a
// late rejection after losing the race can't become an unhandled
// rejection.
if (isSessionPersistEnabled()) {
const finalSnapshot = persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE))
.catch((err: any) => {
console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`);
});
await Promise.race([
finalSnapshot,
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
]);
}
await cfgBrowserManager.close();
cleanSingletonLocks(resolveChromiumProfile());
@ -1730,6 +1811,12 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// after 30 min of HTTP idle because the dead module-level instance still
// reports connectionMode === 'launched'.
activeBrowserManager = cfgBrowserManager;
// Same reason as above: the watchdog reads activeBrowserManager, so the
// instance that can promote itself to headed must be the one that can
// suppress the headed parent-death branch. An embedder-supplied manager
// otherwise promotes silently and the watchdog keeps shutting down on a
// promotion it can no longer see.
cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown;
// Wire the cfg-instance's onDisconnect to run shutdown when the user
// closes the headed browser window. CHAIN any caller-provided handler
@ -2442,6 +2529,14 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
if (!isPairAgentEnabled()) {
// Consent-on-first-use: the /pair-agent skill asks once and sets the
// key; a direct API caller gets the same hint instead of a tunnel.
return new Response(JSON.stringify({
error: 'pair-agent is off (tunnel exposes this browser beyond the machine)',
hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
if (tunnelActive && tunnelUrl && tunnelServer) {
// Verify tunnel is still alive before returning cached URL.
// Probe GET /connect (the only unauth-reachable path on the tunnel
@ -2478,7 +2573,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
const started = await startTunnel({
fetchHandler: makeFetchHandler('tunnel'),
authtoken,
consent: 'pair_agent=on',
consent: 'pair_agent=on (isPairAgentEnabled gate at /tunnel/start)',
});
if (!started.ok) {
return new Response(JSON.stringify({
@ -3096,6 +3191,58 @@ export async function start() {
browserManager.serverPort = port;
// ─── Opt-in session persistence (#778 class) ─────────────────
// BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last
// snapshot, then keep snapshotting on an interval. Launched mode only —
// the headed persistent profile owns its own state. The final snapshot at
// clean shutdown lives in buildFetchHandler's shutdown().
//
// Runs AFTER Bun.serve() + the state-file write, in the BACKGROUND:
// restore re-creates tabs sequentially with up-to-15s goto timeouts while
// the CLI's readiness probe gives up at 8s — one slow/unreachable saved
// URL must never make every `$B` command report "Server failed to start".
// Fire-and-forget: a restore failure is logged and never affects the
// daemon.
if (!skipBrowser && isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') {
const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE);
restoreSessionState(browserManager, sessionStatePath)
.then((restored) => {
if (restored) {
// Counts come from the deserialized snapshot itself — no extra
// saveState() round-trip against pages that may still be loading.
console.log(`[browse] Session state restored: ${restored.cookies.length} cookies / ${restored.pages.length} tabs (BROWSE_PERSIST_STATE=1)`);
} else {
console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)');
}
})
.catch((err: any) => {
console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`);
});
let persistWarned = false;
// In-flight guard: never start a new snapshot while the previous one is
// still pending (a slow page.evaluate would otherwise pile up ticks).
let persistInFlight = false;
sessionPersistInterval = setInterval(() => {
// Shutdown gate (belt; shutdown()'s clearInterval is the suspenders):
// a tick that fires during browser teardown snapshots a degraded state
// (zero tabs) over the good final snapshot.
if (isShuttingDown) return;
if (persistInFlight) return; // skip the tick
persistInFlight = true;
persistSessionState(browserManager, sessionStatePath)
.catch((err: any) => {
// Warn once — a full disk must not spam the log every 30s, and a
// snapshot failure must never kill the daemon (R3).
if (!persistWarned) {
persistWarned = true;
console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`);
}
})
.finally(() => { persistInFlight = false; });
}, sessionPersistIntervalMs());
(sessionPersistInterval as any)?.unref?.();
}
// Navigate to welcome page if in headed mode and still on about:blank
if (browserManager.getConnectionMode() === 'headed') {
try {
@ -3137,7 +3284,9 @@ export async function start() {
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
// pattern: bind a dedicated tunnel listener on an ephemeral port and
// point ngrok.forward() at IT, not the local daemon port.
if (process.env.BROWSE_TUNNEL === '1') {
if (process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()) {
console.error('[browse] BROWSE_TUNNEL=1 ignored: pair-agent is off. Enable once with: gstack-config set pair_agent on');
} else if (process.env.BROWSE_TUNNEL === '1') {
const authtoken = resolveNgrokAuthtoken();
if (!authtoken) {
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
@ -3149,7 +3298,7 @@ export async function start() {
const started = await startTunnel({
fetchHandler: handle.fetchTunnel,
authtoken,
consent: 'pair_agent=on (BROWSE_TUNNEL=1)',
consent: 'pair_agent=on (isPairAgentEnabled gate, BROWSE_TUNNEL=1)',
});
if (!started.ok) {
console.error(`[browse] Failed to start tunnel: ${started.error.message}`);

View File

@ -0,0 +1,178 @@
/**
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies +
* per-tab URL/localStorage/sessionStorage to <stateDir>/session-state.json
* on an interval and at clean shutdown, and restores it on the next launch.
* Kills the auth-lost-on-restart class: a crash or binary-version
* auto-restart no longer silently logs the user out of everything.
*
* Default OFF: cookies on disk (0600) are a real cost the user must opt
* into. Headed mode is excluded the persistent Chromium profile already
* owns that state, and replaying tabs would clobber the user's window.
*
* Disk shape (version 1): { version, savedAt, cookies, pages[{url,
* isActive, storage}] }. loadedHtml and owner are NEVER persisted same
* in-memory-only invariant as `state save|load` (meta-commands.ts): a
* tampered file must not smuggle HTML past load-html's checks or forge tab
* ownership.
*/
import * as fs from 'fs';
import type { BrowserManager, BrowserState } from './browser-manager';
import { writeSecureFile } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling';
/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */
function quarantineCorrupt(filePath: string): void {
try {
fs.renameSync(filePath, `${filePath}.corrupt`);
} catch {
safeUnlinkQuiet(filePath);
}
}
export const SESSION_STATE_FILE = 'session-state.json';
export const SESSION_STATE_VERSION = 1;
/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */
export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.BROWSE_PERSIST_STATE === '1';
}
/** Persist interval (ms). Env override exists for tests. */
export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
}
/**
* Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml,
* loadedHtmlWaitUntil, and owner (in-memory-only invariants).
*/
export function serializeSessionState(state: BrowserState): string {
return JSON.stringify({
version: SESSION_STATE_VERSION,
savedAt: new Date().toISOString(),
cookies: state.cookies,
pages: state.pages.map((p) => ({
url: p.url,
isActive: p.isActive,
storage: p.storage,
})),
}, null, 2);
}
/**
* True when a cookie domain points at an internal-network target a tampered
* state file could use to reach localhost services, *.internal hosts, or
* cloud metadata: `localhost`, `*.internal`, IPv4 loopback literals
* (127.0.0.0/8), IPv6 loopback (`::1`, `[::1]`), and link-local/metadata
* (169.254.0.0/16, which covers 169.254.169.254). Leading-dot domain
* variants (`.127.0.0.1`) are normalized before matching. Single source of
* truth for the persistence restore path here AND `state load`
* (meta-commands.ts).
*/
export function isInternalCookieDomain(domain: string): boolean {
const d = domain.startsWith('.') ? domain.slice(1) : domain;
if (d === 'localhost' || d.endsWith('.internal')) return true;
if (d === '::1' || d === '[::1]') return true; // IPv6 loopback
if (/^127\./.test(d)) return true; // IPv4 loopback block
if (/^169\.254\./.test(d)) return true; // link-local incl. cloud metadata
return false;
}
/**
* Cookie hygiene shared with `state load` (meta-commands.ts): drop malformed
* cookies and internal-network domains (see isInternalCookieDomain).
*/
export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
return cookies.filter((c: any) => {
if (typeof c !== 'object' || !c) return false;
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
if (typeof c.domain !== 'string' || !c.domain) return false;
return !isInternalCookieDomain(c.domain);
}) as BrowserState['cookies'];
}
/**
* Parse + validate the on-disk shape into a BrowserState. Returns null for
* anything malformed (corrupt JSON, wrong version, missing arrays).
* loadedHtml/owner are stripped unconditionally even if present on disk.
*/
export function deserializeSessionState(raw: string): BrowserState | null {
let data: any;
try {
data = JSON.parse(raw);
} catch {
return null;
}
if (!data || data.version !== SESSION_STATE_VERSION) return null;
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null;
return {
cookies: filterSessionCookies(data.cookies),
pages: data.pages.map((p: any) => ({
url: typeof p?.url === 'string' ? p.url : '',
isActive: Boolean(p?.isActive),
storage: p?.storage && typeof p.storage === 'object'
? {
localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {},
sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {},
}
: null,
// NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk.
})),
};
}
/**
* Snapshot the live session to disk (0600). No-op outside launched
* (headless) mode the headed persistent profile owns its own state.
*/
export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
if (bm.getConnectionMode() !== 'launched') return;
const state = await bm.saveState();
// Atomic replace: stage the new snapshot beside the target, then rename
// over it. A crash mid-write must never destroy the previous good
// snapshot — surviving crashes is the point of this feature.
const tmpPath = `${filePath}.tmp`;
writeSecureFile(tmpPath, serializeSessionState(state));
try {
fs.renameSync(tmpPath, filePath);
} catch (err) {
safeUnlinkQuiet(tmpPath);
throw err;
}
}
/**
* Restore a persisted session into a freshly launched manager. Returns the
* restored (already-filtered) state so callers can log counts without an
* extra saveState() round-trip, or null when there was nothing to restore
* (missing file, or corrupt data which is warned, quarantined, and skipped
* rather than blocking launch). restoreState re-validates every URL before
* navigating.
*/
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<BrowserState | null> {
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch (err: any) {
if (err?.code === 'ENOENT') return null;
throw err;
}
const state = deserializeSessionState(raw);
if (!state) {
// Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a
// 3-week-later bug report is reconstructable from the artifact.
console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
quarantineCorrupt(filePath);
return null;
}
// launch() opens one blank tab; replace it rather than restoring alongside.
await bm.closeAllPages();
await bm.restoreState(state);
return state;
}

View File

@ -21,6 +21,7 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { readGstackConfigYamlKey } from './config';
function gstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
@ -43,17 +44,51 @@ async function ensureDir(): Promise<void> {
}
let telemetryDisabled: boolean | null = null;
function isDisabled(): boolean {
/**
* Is telemetry disabled for this process? Telemetry is OPT-IN: the consent
* prompt writes a granted tier ('community' | 'anonymous') to
* ~/.gstack/config.yaml, and only a granted tier enables emission. Tiers,
* checked in order:
*
* 1. Env hint GSTACK_TELEMETRY_OFF=1 (set by preambles and test
* harnesses): always disabled, even over a granted config tier.
* 2. Persistent tier via the shared flat-YAML helper in config.ts (same
* parser as the pair-agent gate, so the two consent gates never drift):
* explicit `telemetry: off` disables; 'community'/'anonymous' enable.
* 3. Default: DISABLED. An absent key, absent file, or unrecognized value
* means consent was never granted matching bin/gstack-config's
* DEFAULTS table, which reports 'off' for an unset telemetry key.
* Anything else would be a split-brain where `gstack-config get
* telemetry` tells the user 'off' while a direct-$B daemon emits.
* One escape hatch: GSTACK_TELEMETRY_OFF=0 is a harness-side consent
* assertion that flips this DEFAULT only (test harnesses exercising the
* write path against a scratch GSTACK_HOME) it never overrides an
* explicit `telemetry: off` the user wrote.
*
* Exported so tests can pin the consent gate directly; the cached verdict
* resets via _resetTelemetryCache.
*/
export function isTelemetryDisabled(): boolean {
if (telemetryDisabled !== null) return telemetryDisabled;
// Check env (set by preamble or test harnesses).
// Env kill switch (set by preamble or test harnesses): beats everything.
if (process.env.GSTACK_TELEMETRY_OFF === '1') {
telemetryDisabled = true;
return true;
}
// Conservative default: telemetry ON unless explicitly off. Users opt out via
// gstack-config set telemetry off (preamble reads this; we trust the env hint).
telemetryDisabled = false;
return false;
// Persistent tier: an explicit user-written value always wins next.
const tier = readGstackConfigYamlKey('telemetry');
if (tier === 'off') {
telemetryDisabled = true;
return true;
}
if (tier === 'community' || tier === 'anonymous') {
telemetryDisabled = false;
return false;
}
// No granted consent on record (absent key/file, unrecognized value):
// disabled — unless the harness asserted consent via the env seam.
telemetryDisabled = process.env.GSTACK_TELEMETRY_OFF !== '0';
return telemetryDisabled;
}
export interface TelemetryEvent {
@ -63,7 +98,7 @@ export interface TelemetryEvent {
/** Fire-and-forget log. Never throws. */
export function logTelemetry(payload: TelemetryEvent): void {
if (isDisabled()) return;
if (isTelemetryDisabled()) return;
const enriched = { ...payload, ts: new Date().toISOString() };
ensureDir()
.then(() => fs.appendFile(telemetryFile(), JSON.stringify(enriched) + '\n', 'utf8'))

View File

@ -1,11 +1,13 @@
import { describe, test, expect, afterAll } from 'bun:test';
import { describe, test, expect, afterAll, setDefaultTimeout } from 'bun:test';
import * as path from 'path';
// Every test here spawnSync's a `node` child; Windows CI cold-start (AV scan,
// first-touch of node.exe) alone can blow bun's 5s default — observed 5,007ms
// on a 50ms sleep test. Subprocess budget, not assertion looseness.
setDefaultTimeout(20_000);
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
// Forward slashes so the path survives interpolation into a JS string literal
// on Windows, which is the platform this polyfill exists for.
const requirePath = polyfillPath.replace(/\\/g, '/');
describe('bun-polyfill', () => {
// We test the polyfill by requiring it in a subprocess under Node.js
@ -13,7 +15,7 @@ describe('bun-polyfill', () => {
test('Bun.sleep resolves after delay', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
require(${JSON.stringify(polyfillPath)});
(async () => {
const start = Date.now();
await Bun.sleep(50);
@ -27,7 +29,7 @@ describe('bun-polyfill', () => {
test('Bun.spawnSync runs a command and returns stdout', () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
require(${JSON.stringify(polyfillPath)});
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
console.log(r.stdout.toString().trim());
console.log('exit:' + r.exitCode);
@ -39,7 +41,7 @@ describe('bun-polyfill', () => {
test('Bun.spawn launches a process with pid', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
require(${JSON.stringify(polyfillPath)});
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
@ -51,179 +53,9 @@ describe('bun-polyfill', () => {
expect(lines[2]).toBe('HAS_UNREF');
});
// Bun.spawn parity: `proc.exited` is a Promise resolving to the exit code.
// The DPAPI helper and isBrowserRunning both `await proc.exited`; without
// it the awaits resolve immediately to `undefined` and the caller reads
// stdout before the child has produced it — surfacing as a silent failure.
test('Bun.spawn exposes proc.exited that resolves to the exit code', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['node', '-e', 'process.exit(0)'], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log(typeof p.exited === 'object' && typeof p.exited.then === 'function' ? 'IS_PROMISE' : 'NOT_PROMISE');
console.log('exit:' + await p.exited);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
const lines = result.stdout.toString().trim().split('\n');
expect(lines[0]).toBe('IS_PROMISE');
expect(lines[1]).toBe('exit:0');
});
test('Bun.spawn proc.exited reflects non-zero exit codes', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['node', '-e', 'process.exit(3)'], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log('exit:' + await p.exited);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('exit:3');
});
test('Bun.spawn proc.exited resolves before reading stdout (no race)', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
// Real-world pattern: write to stdout, then exit. Awaiting proc.exited
// before reading must guarantee the bytes are flushed.
const p = Bun.spawn(['node', '-e', 'process.stdout.write("ready"); process.exit(0)'], {
stdio: ['ignore', 'pipe', 'ignore']
});
const code = await p.exited;
const out = await new Response(p.stdout).text();
console.log(out + ':' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('ready:0');
});
// Spawn-failure case: Node emits 'error' but not 'exit' when the binary
// is missing, so listening only for 'exit' hangs `await proc.exited`
// forever. The lifecycle promise must resolve on either event.
test('Bun.spawn proc.exited resolves on spawn failure (missing binary)', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const p = Bun.spawn(['this-binary-does-not-exist-zzz-' + Date.now()], {
stdio: ['ignore', 'pipe', 'pipe']
});
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
]).catch(() => 'TIMEOUT');
console.log('exit:' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
// Anything other than 'TIMEOUT' (and ideally a non-zero number) means the
// lifecycle promise resolved on the spawn error.
const out = result.stdout.toString().trim();
expect(out).not.toBe('exit:TIMEOUT');
expect(out).toMatch(/^exit:\d+$/);
});
// GSTACK_SPAWN_MAX_BUFFER caps the drain so a runaway child can't OOM the
// server. Past the cap, the pipe keeps flowing (child doesn't block) but
// further bytes are dropped. Set a small cap, write more than that, assert
// the captured stdout equals the cap and the child exits cleanly.
test('Bun.spawn caps buffered output at GSTACK_SPAWN_MAX_BUFFER', async () => {
const result = Bun.spawnSync(['node', '-e', `
process.env.GSTACK_SPAWN_MAX_BUFFER = '${1024}';
require('${requirePath}');
(async () => {
// Child writes 10 KB; cap is 1 KB; drained output should be exactly 1 KB
// and exit should still resolve cleanly (child not back-pressured to death).
const p = Bun.spawn(
['node', '-e', 'process.stdout.write("y".repeat(10 * 1024)); process.exit(0)'],
{ stdio: ['ignore', 'pipe', 'ignore'] }
);
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 3000))
]).catch(() => 'TIMEOUT');
const out = await new Response(p.stdout).text();
console.log(out.length + ':' + code);
})();
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('1024:0');
});
// Regression for the pipe-blocking case: if the child writes more than the
// OS pipe buffer (~16-64 KB) and the polyfill doesn't drain eagerly, the
// child blocks in write() and `exit` never fires. 1 MB is well past every
// OS pipe buffer size. Pre-fix this test hangs forever; post-fix it returns
// in <500ms. Bun's default per-test timeout is 5s — generous here.
test('Bun.spawn drains large stdout so proc.exited still resolves', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
(async () => {
const ONE_MB = 1024 * 1024;
// Exit in the write callback, not straight after write(): on modern
// Node a pipe write past the OS buffer is async, and process.exit()
// right after write() truncates at ~64 KB even with a live reader.
// The callback only fires once the full MB is flushed — which still
// requires the parent to drain, so the regression (no eager drain →
// child blocks → timeout) is still caught.
const p = Bun.spawn(
['node', '-e', 'process.stdout.write("x".repeat(' + ONE_MB + '), () => process.exit(0))'],
{ stdio: ['ignore', 'pipe', 'ignore'] }
);
const code = await Promise.race([
p.exited,
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 10000))
]).catch(e => 'TIMEOUT');
const out = await new Response(p.stdout).text();
console.log(out.length + ':' + code);
})().catch((e) => { console.log('THREW:' + e.message); });
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('1048576:0');
}, 15000);
// windowsHide is the one option where Node's default is the opposite of
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
// in translation makes every spawned child pop a window on Windows, which
// is the platform this whole file exists for. Both shims are covered.
test('Bun.spawn defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('Bun.spawnSync defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawnSync;
let seen;
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawnSync(['node', '-e', '']);
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('an explicit windowsHide:false is honored', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require('${requirePath}');
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
});
test('Bun.serve creates an HTTP server that responds', async () => {
const result = Bun.spawnSync(['node', '-e', `
require('${requirePath}');
require(${JSON.stringify(polyfillPath)});
const server = Bun.serve({
port: 0, // Note: polyfill uses port directly, so we pick one
hostname: '127.0.0.1',
@ -242,4 +74,48 @@ describe('bun-polyfill', () => {
expect(lines[0]).toBe('HAS_STOP');
expect(lines[1]).toBe('HAS_PORT');
});
// windowsHide is the one option where Node's default is the opposite of
// Bun's: Node shows the child's console window, Bun hides it. Dropping it
// in translation makes every spawned child pop a window on Windows, which
// is the platform this whole file exists for. Both shims are covered, and
// an explicit windowsHide:false must survive forwarding (#2523 + #2539).
test('Bun.spawn defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require(${JSON.stringify(polyfillPath)});
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'] });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('Bun.spawnSync defaults windowsHide to true', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawnSync;
let seen;
cp.spawnSync = (c, a, o) => { seen = o; return orig(c, a, o); };
require(${JSON.stringify(polyfillPath)});
Bun.spawnSync(['node', '-e', '']);
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:true');
});
test('an explicit windowsHide:false is honored', () => {
const result = Bun.spawnSync(['node', '-e', `
const cp = require('child_process');
const orig = cp.spawn;
let seen;
cp.spawn = (c, a, o) => { seen = o; return orig(c, a, o); };
require(${JSON.stringify(polyfillPath)});
Bun.spawn(['node', '-e', ''], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
console.log('windowsHide:' + seen.windowsHide);
`], { stdout: 'pipe', stderr: 'pipe' });
expect(result.stdout.toString().trim()).toBe('windowsHide:false');
});
});

View File

@ -0,0 +1,134 @@
/**
* #1781 busy-vs-dead pinning test the "recover from a busy daemon" leg of
* the browser-lifecycle contract, previously untested.
*
* Wedges a fake daemon: /health answers healthy, but the FIRST POST /command
* hard-destroys the socket (the CLI sees ECONNRESET exactly what a
* single-threaded daemon under beacon load looks like). The daemon "PID"
* is a live sleep child.
*
* Contract under test (cli.ts sendCommand + probeHealthWithBackoff):
* - CLI must NOT kill the live PID and must NOT restart the daemon
* (a restart drops tab/cookie state the original crash-loop bug).
* - It probes /health, sees alive, and retries the SAME command against
* the SAME daemon instance.
*
* Fails on pre-#1781 code (which killed + restarted on any conn error) and
* on any regression that reorders the busy-probe before the alive check.
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as http from 'http';
import { isProcessAlive } from '../src/error-handling';
const BOOT_ID = `boot-${Date.now()}`;
interface FakeDaemon {
port: number;
commandRequests: number;
close: () => Promise<void>;
}
/** /health healthy; first POST /command → socket destroy; then 200 + BOOT_ID. */
async function startWedgedDaemon(): Promise<FakeDaemon> {
const state = { commandRequests: 0 };
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy' }));
return;
}
if (req.url === '/command' && req.method === 'POST') {
state.commandRequests += 1;
if (state.commandRequests === 1) {
req.socket.destroy(); // wedged: connection dies mid-request
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`RECOVERED ${BOOT_ID}`);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
return {
port: addr.port,
get commandRequests() { return state.commandRequests; },
close: () => new Promise((r) => server.close(() => r())),
};
}
function runCli(args: string[], env: Record<string, string>, timeoutMs = 20_000):
Promise<{ code: number; stdout: string; stderr: string }> {
const cliPath = path.resolve(__dirname, '../src/cli.ts');
return new Promise((resolve) => {
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
let stdout = ''; let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
let daemonPidChild: ChildProcess | null = null;
afterAll(() => { daemonPidChild?.kill('SIGKILL'); });
describe('#1781 busy-daemon recovery (CLI integration)', () => {
test('retries without kill; same daemon instance, state file untouched', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-busy-'));
const stateFile = path.join(tmpDir, 'browse.json');
const daemon = await startWedgedDaemon();
try {
// A live process standing in for the daemon PID. If the CLI takes the
// dead path it SIGTERMs this child — the aliveness assert catches it.
daemonPidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
const daemonPid = daemonPidChild.pid!;
const stateContent = {
pid: daemonPid,
port: daemon.port,
token: 'busy-test-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
};
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
env.BROWSE_STATE_FILE = stateFile;
const result = await runCli(['status'], env);
// Recovered: retried the same command against the same daemon instance.
expect(result.code).toBe(0);
expect(result.stdout).toContain(`RECOVERED ${BOOT_ID}`);
// The fork's CLI announces the busy retry on stderr; ours retries at the
// probe layer without a message. Either is fine — the load-bearing
// behavior is retry-without-kill, asserted below.
expect(daemon.commandRequests).toBe(2); // wedged once, served once
// Never killed, never restarted — tab/cookie state intact.
expect(result.stderr).not.toContain('Restarting');
expect(isProcessAlive(daemonPid)).toBe(true);
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
} finally {
// Cleanup must run even when an assertion throws — otherwise a failed
// run leaks the wedged fake daemon and the tmp dir.
await daemon.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});

View File

@ -1,8 +1,22 @@
/**
* #1084 diagnostics merged-design shape.
*
* Main's smell wave pinned a log-and-return-null acquireServerLock; this
* branch keeps the typed ServerLockError + bounded-retry design (fully pinned
* in server-lock-errors.test.ts). This file re-expresses the non-redundant
* assertion intents from the wave's test against the kept design:
* - unexpected open failures surface the REAL errno + lock path (typed
* throw), never phantom "another process holds the lock" contention;
* - holder-PID read failures surface errno + lock path the same way;
* - genuine live contention stays SILENT (null return, no stderr noise).
* Exact duplicates of server-lock-errors.test.ts coverage (stale-lock
* reacquire, ENOENT self-heal, EACCES throw) are deliberately not repeated.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { acquireServerLock } from '../src/cli';
import { acquireServerLock, ServerLockError } from '../src/cli';
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
@ -27,14 +41,27 @@ function captureErrors<T>(fn: () => T): { result: T; messages: string[] } {
}
describe('browse CLI server lock diagnostics (#1084)', () => {
test('logs non-EEXIST open failures instead of reporting phantom lock contention', () => {
test('unexpected open failures throw ServerLockError with the real errno — not phantom lock contention', () => {
if (process.platform === 'win32') return; // ENOTDIR errno mapping differs on Windows
withTempDir((dir) => {
const lockPath = path.join(dir, 'missing-parent', 'browse.json.lock');
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
// A FILE where a directory is expected: open('wx') fails ENOTDIR — an
// errno that is neither contention (EEXIST) nor the self-healing
// missing-dir case (ENOENT). The old code's bare catch would have
// reported "another process holds the lock" forever.
const blocker = path.join(dir, 'blocker');
fs.writeFileSync(blocker, 'not a directory\n');
const lockPath = path.join(blocker, 'browse.json.lock');
expect(result).toBeNull();
expect(messages.join('\n')).toContain('unexpected ENOENT while opening');
expect(messages.join('\n')).toContain(lockPath);
let thrown: any = null;
try {
acquireServerLock(lockPath);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ServerLockError);
expect(thrown.code).toBe('ENOTDIR');
expect(thrown.message).toContain('E_SERVER_LOCK (ENOTDIR)');
expect(thrown.message).toContain(lockPath);
});
});
@ -50,30 +77,25 @@ describe('browse CLI server lock diagnostics (#1084)', () => {
});
});
test('logs holder PID read failures with code and lock path', () => {
test('holder PID read failures throw ServerLockError with code and lock path', () => {
withTempDir((dir) => {
// Lock path exists but is a DIRECTORY: open('wx') → EEXIST (looks like
// contention), then the holder-PID read fails EISDIR. The kept design
// surfaces that errno + path in a typed error instead of retrying or
// reporting phantom contention.
const lockPath = path.join(dir, 'browse.json.lock');
fs.mkdirSync(lockPath);
const { result, messages } = captureErrors(() => acquireServerLock(lockPath));
expect(result).toBeNull();
expect(messages.join('\n')).toContain('unexpected EISDIR while reading holder PID from');
expect(messages.join('\n')).toContain(lockPath);
});
});
test('removes stale lock and reacquires it', () => {
withTempDir((dir) => {
const lockPath = path.join(dir, 'browse.json.lock');
fs.writeFileSync(lockPath, 'not-a-pid\n');
const release = acquireServerLock(lockPath);
expect(release).toBeFunction();
expect(fs.readFileSync(lockPath, 'utf-8').trim()).toBe(String(process.pid));
release?.();
expect(fs.existsSync(lockPath)).toBe(false);
let thrown: any = null;
try {
acquireServerLock(lockPath);
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ServerLockError);
expect(thrown.code).toBe('EISDIR');
expect(thrown.message).toContain('E_SERVER_LOCK (EISDIR)');
expect(thrown.message).toContain(lockPath);
});
});
});

View File

@ -16,7 +16,7 @@ import { handleReadCommand as _handleReadCommand } from '../src/read-commands';
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleReadCommand(cmd, args, b.getActiveSession());
_handleReadCommand(cmd, args, b.getActiveSession(), b);
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
import { generateCompareHtml } from '../../design/src/compare';

View File

@ -32,9 +32,13 @@ const CONTENT_SCRIPT_SENDER = { id: OWN_ID, url: 'https://evil.example/page', ta
const FOREIGN_SENDER = { id: FOREIGN_ID, url: `chrome-extension://${FOREIGN_ID}/background.html` };
const NO_URL_SENDER = { id: OWN_ID };
// 'sidebar-command' is no longer a message type at all — the chat-queue path
// was ripped along with the /sidebar-command endpoint, so background.js now
// rejects it pre-gate as an unknown type (no response, nothing to leak). It is
// pinned separately below as a representative unknown type.
const PRIVILEGED = [
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
'command', 'sidebar-command', 'getTabState',
'command', 'getTabState',
];
// Content-script-originated flows that must keep working.
const CONTENT_SCRIPT_TYPES = ['openSidePanel', 'elementPicked', 'pickerCancelled', 'inspectResult'];
@ -216,6 +220,16 @@ describe('background.js onMessage listener (behavioral)', () => {
}
});
test('retired sidebar-command type is rejected pre-gate with no response and no leaks', () => {
// Even from the most-trusted sender shape, a type outside ALLOWED_TYPES
// never reaches a handler: no sendResponse, no token/port fields possible.
for (const sender of [PAGE_SENDER, CONTENT_SCRIPT_SENDER, FOREIGN_SENDER, NO_URL_SENDER]) {
const r = dispatch(listener, { type: 'sidebar-command', message: 'hi' }, sender);
expect(r.responded).toBe(false);
expectDenied(r);
}
});
test('denied setPort never persists the attacker port', () => {
const before = calls.storageSet.length;
const r = dispatch(listener, { type: 'setPort', port: 6666 }, CONTENT_SCRIPT_SENDER);

View File

@ -22,6 +22,7 @@ import {
writeSecureFile,
appendSecureFile,
mkdirSecure,
repairBrokenDacl,
__resetWarnedForTests,
} from '../src/file-permissions';
@ -175,4 +176,35 @@ describe('mkdirSecure', () => {
expect(fs.existsSync(path.join(tmpDir, 'a', 'b'))).toBe(true);
expect(fs.existsSync(d)).toBe(true);
});
test('created directory is listable by the creating process', () => {
// #1605 contract: whatever ACL hardening happens, the client must be
// able to read its own state dir immediately after creation.
const d = path.join(tmpDir, 'state');
mkdirSecure(d);
fs.writeFileSync(path.join(d, 'browse.json'), '{}');
expect(fs.readdirSync(d)).toContain('browse.json');
});
});
describe('repairBrokenDacl', () => {
test('is a no-op on non-Windows platforms', () => {
if (process.platform === 'win32') return;
const d = path.join(tmpDir, 'dir');
fs.mkdirSync(d);
expect(() => repairBrokenDacl(d)).not.toThrow();
});
test('on Windows, does not throw and directory stays listable', () => {
if (process.platform !== 'win32') return;
const d = path.join(tmpDir, 'dir');
fs.mkdirSync(d);
expect(() => repairBrokenDacl(d)).not.toThrow();
expect(() => fs.readdirSync(d)).not.toThrow();
});
test('on Windows, swallows icacls failure on a nonexistent path', () => {
if (process.platform !== 'win32') return;
expect(() => repairBrokenDacl(path.join(tmpDir, 'nonexistent'))).not.toThrow();
});
});

View File

@ -15,12 +15,20 @@ const SCRIPT = join(import.meta.dir, '..', '..', 'bin', 'gstack-config');
let stateDir: string;
function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
// The script resolves its state dir as GSTACK_STATE_ROOT > GSTACK_HOME >
// GSTACK_STATE_DIR > $HOME/.gstack. Strip the higher-precedence vars so a
// stray value in the harness env (another test file's leftovers, operator
// shell) can never outrank the per-test GSTACK_STATE_DIR isolation.
const env: Record<string, string | undefined> = {
...process.env,
GSTACK_STATE_DIR: stateDir,
};
delete env.GSTACK_STATE_ROOT;
delete env.GSTACK_HOME;
Object.assign(env, extraEnv); // per-test overrides always win, deliberately
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_STATE_DIR: stateDir,
...extraEnv,
},
env,
stdout: 'pipe',
stderr: 'pipe',
});

View File

@ -17,14 +17,21 @@ let gstackDir: string;
let stateDir: string;
function run(extraEnv: Record<string, string> = {}, args: string[] = []) {
// gstack-config (which this script shells out to for update_check) resolves
// state as GSTACK_STATE_ROOT > GSTACK_HOME > GSTACK_STATE_DIR > ~/.gstack.
// Strip the higher-precedence vars so harness-env leftovers can never
// outrank the per-test GSTACK_STATE_DIR isolation.
const env: Record<string, string | undefined> = {
...process.env,
GSTACK_DIR: gstackDir,
GSTACK_STATE_DIR: stateDir,
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
};
delete env.GSTACK_STATE_ROOT;
delete env.GSTACK_HOME;
Object.assign(env, extraEnv); // per-test overrides always win, deliberately
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
GSTACK_DIR: gstackDir,
GSTACK_STATE_DIR: stateDir,
GSTACK_REMOTE_URL: `file://${join(gstackDir, 'REMOTE_VERSION')}`,
...extraEnv,
},
env,
stdout: 'pipe',
stderr: 'pipe',
});

View File

@ -0,0 +1,124 @@
/**
* Pair-agent opt-in gate.
*
* The remote pair-agent (ngrok tunnel) is OFF by default. All three activation
* points CLI auto-start, the /tunnel/start route, and the BROWSE_TUNNEL=1
* startup path route through the single `isPairAgentEnabled()` guard. This
* test pins the guard's behavior (the root cause) plus a source-level tripwire
* that each call site actually consults it.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isPairAgentEnabled } from '../src/config';
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8');
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
const savedEnv = { GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_PAIR_AGENT: process.env.GSTACK_PAIR_AGENT };
const tmpHomes: string[] = [];
function tmpHomeWith(config: Record<string, string> | null): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-pair-'));
tmpHomes.push(dir);
if (config !== null) {
// Canonical store: flat YAML lines, the shape bin/gstack-config writes.
const yaml = Object.entries(config).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n';
fs.writeFileSync(path.join(dir, 'config.yaml'), yaml);
}
process.env.GSTACK_HOME = dir;
delete process.env.GSTACK_PAIR_AGENT;
return dir;
}
afterEach(() => {
for (const k of ['GSTACK_HOME', 'GSTACK_PAIR_AGENT'] as const) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true });
});
describe('isPairAgentEnabled — fail-closed default', () => {
test('OFF when no config store exists', () => {
tmpHomeWith(null);
expect(isPairAgentEnabled()).toBe(false);
});
test('OFF when config has no pair_agent key', () => {
tmpHomeWith({ telemetry: 'off' });
expect(isPairAgentEnabled()).toBe(false);
});
test('ON via the config.json fallback shape too', () => {
const dir = tmpHomeWith(null);
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ pair_agent: 'on' }));
expect(isPairAgentEnabled()).toBe(true);
});
test('OFF when pair_agent is explicitly "off"', () => {
tmpHomeWith({ pair_agent: 'off' });
expect(isPairAgentEnabled()).toBe(false);
});
test('ON only when pair_agent is exactly "on"', () => {
tmpHomeWith({ pair_agent: 'on' });
expect(isPairAgentEnabled()).toBe(true);
});
test('OFF when the store is malformed (fail-closed)', () => {
const dir = tmpHomeWith(null);
fs.writeFileSync(path.join(dir, 'config.yaml'), 'pair_agent: banana\n');
fs.writeFileSync(path.join(dir, 'config.json'), '{ not json');
expect(isPairAgentEnabled()).toBe(false);
});
test('env override wins: GSTACK_PAIR_AGENT=on forces ON even with config off', () => {
tmpHomeWith({ pair_agent: 'off' });
process.env.GSTACK_PAIR_AGENT = 'on';
expect(isPairAgentEnabled()).toBe(true);
});
test('env override wins: GSTACK_PAIR_AGENT=off forces OFF even with config on', () => {
tmpHomeWith({ pair_agent: 'on' });
process.env.GSTACK_PAIR_AGENT = 'off';
expect(isPairAgentEnabled()).toBe(false);
});
});
describe('gate wiring — every tunnel activation point consults the guard', () => {
test('CLI auto-start is gated (never auto-starts when disabled)', () => {
// pairEnabled short-circuits the ngrok probe so the tunnel can't auto-start.
expect(CLI_SRC).toContain('const pairEnabled = isPairAgentEnabled();');
expect(CLI_SRC).toContain('const ngrokAvailable = pairEnabled && isNgrokAvailable();');
});
test('CLI consent-off branch names the real remedy, never ngrok reinstall', () => {
// When pair_agent is off but ngrok is installed+authed, telling the user
// to `ngrok config add-authtoken` can never fix it — the gate is consent,
// not tooling. The consent branch must carry the same remedy wording as
// the /tunnel/start 403 body, and must not mention ngrok setup.
const branchAt = CLI_SRC.indexOf('} else if (!pairEnabled) {');
expect(branchAt).toBeGreaterThan(-1);
const branchEnd = CLI_SRC.indexOf('} else {', branchAt);
expect(branchEnd).toBeGreaterThan(branchAt);
const branch = CLI_SRC.slice(branchAt, branchEnd);
expect(branch).toContain('gstack-config set pair_agent on');
expect(branch).toContain('/pair-agent');
expect(branch).not.toContain('ngrok config add-authtoken');
expect(branch).not.toContain('install ngrok');
});
test('/tunnel/start refuses with the enable hint when disabled', () => {
const startIdx = SERVER_SRC.indexOf("url.pathname === '/tunnel/start'");
const block = SERVER_SRC.slice(startIdx, startIdx + 1200);
expect(block).toContain('if (!isPairAgentEnabled())');
expect(block).toContain('gstack-config set pair_agent on');
});
test('BROWSE_TUNNEL=1 startup skips tunnel bind when disabled', () => {
expect(SERVER_SRC).toContain("process.env.BROWSE_TUNNEL === '1' && !isPairAgentEnabled()");
});
});

View File

@ -0,0 +1,148 @@
/**
* Unit tests for the extracted poisoned-bundle self-heal probe (#2242).
*
* probePoisonedChromiumBundle() detects a Chromium bundle mutated by the
* pre-v1.64 in-place rebrand (Info.plist contains "GStack Browser"),
* removes it so `playwright install chromium` actually re-downloads, and
* throws a typed PoisonedBundleError with the remediation command.
*
* Contracts pinned here:
* - standard cache layout (chromium-<rev>/chrome-mac/<name>.app): the
* WHOLE revision dir is removed, INSTALLATION_COMPLETE marker included
* (leaving the marker makes the recommended re-fetch a no-op)
* - non-cache layout: the .app + sibling install markers are removed,
* nothing else
* - clean bundle: untouched, no throw
* - GSTACK_CHROMIUM_PATH bundles (custom/embedder) are NEVER deleted:
* the probe refuses to act on that executable, and both call sites
* (launchHeaded + handoff) only pass chromium.executablePath()
* - the rethrow guard at the call sites is typed (instanceof), not a
* fragile message-string match
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { probePoisonedChromiumBundle, PoisonedBundleError } from '../src/browser-manager';
const SRC = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'),
'utf-8',
);
const POISONED_PLIST =
'<plist><dict><key>CFBundleName</key><string>GStack Browser</string></dict></plist>';
const CLEAN_PLIST =
'<plist><dict><key>CFBundleName</key><string>Google Chrome for Testing</string></dict></plist>';
let tmpDir: string;
let savedCustomPath: string | undefined;
/** Build <parentDir>/<name>.app with a plist and executable; return the executable path. */
function makeApp(parentDir: string, plist: string): { appDir: string; exe: string } {
const appDir = path.join(parentDir, 'Google Chrome for Testing.app');
const macos = path.join(appDir, 'Contents', 'MacOS');
fs.mkdirSync(macos, { recursive: true });
fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), plist);
const exe = path.join(macos, 'Google Chrome for Testing');
fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 });
return { appDir, exe };
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'poison-probe-'));
savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
delete process.env.GSTACK_CHROMIUM_PATH;
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
});
describe('probePoisonedChromiumBundle — poisoned cache bundle', () => {
test('standard cache layout: whole chromium-<rev> dir removed (markers included), typed error thrown', () => {
const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234');
const { exe } = makeApp(path.join(revDir, 'chrome-mac'), POISONED_PLIST);
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
fs.writeFileSync(path.join(revDir, 'DEPENDENCIES_VALIDATED'), '');
let caught: unknown;
try {
probePoisonedChromiumBundle(exe);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PoisonedBundleError);
// The message is the user's remediation — it must carry the command.
expect((caught as Error).message).toContain('playwright install chromium');
// Whole revision dir gone: leaving INSTALLATION_COMPLETE behind makes
// `playwright install chromium` no-op ("is already downloaded") and the
// remediation we just printed would do nothing.
expect(fs.existsSync(revDir)).toBe(false);
});
test('non-cache layout: only the .app + sibling install markers removed, neighbors survive', () => {
const parentDir = path.join(tmpDir, 'custom-bundles');
const { appDir, exe } = makeApp(parentDir, POISONED_PLIST);
fs.writeFileSync(path.join(parentDir, 'INSTALLATION_COMPLETE'), '');
fs.writeFileSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'), '');
fs.writeFileSync(path.join(parentDir, 'unrelated.txt'), 'keep me');
expect(() => probePoisonedChromiumBundle(exe)).toThrow(PoisonedBundleError);
expect(fs.existsSync(appDir)).toBe(false);
expect(fs.existsSync(path.join(parentDir, 'INSTALLATION_COMPLETE'))).toBe(false);
expect(fs.existsSync(path.join(parentDir, 'DEPENDENCIES_VALIDATED'))).toBe(false);
// The parent dir itself and unrelated files are NOT swept.
expect(fs.readFileSync(path.join(parentDir, 'unrelated.txt'), 'utf-8')).toBe('keep me');
});
});
describe('probePoisonedChromiumBundle — clean and missing bundles', () => {
test('clean plist: untouched, no throw', () => {
const revDir = path.join(tmpDir, 'ms-playwright', 'chromium-1234');
const { appDir, exe } = makeApp(path.join(revDir, 'chrome-mac'), CLEAN_PLIST);
fs.writeFileSync(path.join(revDir, 'INSTALLATION_COMPLETE'), '');
expect(() => probePoisonedChromiumBundle(exe)).not.toThrow();
expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(path.join(revDir, 'INSTALLATION_COMPLETE'))).toBe(true);
});
test('no plist at the probed path: no-op, no throw (bundle not installed yet)', () => {
expect(() =>
probePoisonedChromiumBundle(path.join(tmpDir, 'nope.app', 'Contents', 'MacOS', 'nope')),
).not.toThrow();
});
});
describe('probePoisonedChromiumBundle — GSTACK_CHROMIUM_PATH is never deleted', () => {
test('probe refuses to act on the GSTACK_CHROMIUM_PATH executable, even when poisoned', () => {
// A custom/embedder bundle (GStack Browser.app wrapper) legitimately
// contains "GStack Browser" in its plist — that is its branding, not
// cache poison. Deleting it would destroy the embedder's product.
const { appDir, exe } = makeApp(path.join(tmpDir, 'GStack Browser.app-parent'), POISONED_PLIST);
process.env.GSTACK_CHROMIUM_PATH = exe;
expect(() => probePoisonedChromiumBundle(exe)).not.toThrow();
expect(fs.existsSync(path.join(appDir, 'Contents', 'Info.plist'))).toBe(true);
expect(fs.existsSync(exe)).toBe(true);
});
test('caller contract: both headed launch paths probe chromium.executablePath() only', () => {
// launchHeaded + handoff each call the probe with the Playwright-cache
// path. No call site may ever pass the custom-bundle env var.
const calls = SRC.match(/probePoisonedChromiumBundle\(chromium\.executablePath\(\)\)/g) || [];
expect(calls.length).toBeGreaterThanOrEqual(2);
expect(SRC).not.toMatch(/probePoisonedChromiumBundle\([^)]*GSTACK_CHROMIUM_PATH/);
});
});
describe('typed rethrow guard at the call sites', () => {
test('instanceof PoisonedBundleError, not message-string sniffing', () => {
expect(SRC).not.toContain("includes('poisoned bundle')");
expect(SRC).toMatch(/instanceof PoisonedBundleError/);
});
});

View File

@ -54,10 +54,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
expect(isProcessAlive(2147483646)).toBe(false);
});
test('3. isProcessAlive spawns NO subprocess', () => {
test('3. isProcessAlive spawns NO subprocess on POSIX (signal-0 path)', () => {
// The heart of the bug: a liveness probe that forks is slow enough to
// time out, and a timed-out probe silently answers "dead". Signal 0
// cannot time out because it never leaves the process.
//
// Merged design note: on win32 the helper DOES keep a single hardened
// tasklist probe (windowsHide, bounded timeout, quoted-CSV PID match)
// because Bun's process.kill(pid, 0) throws ESRCH for live Windows PIDs
// in compiled binaries. The POSIX path stays subprocess-free.
if (process.platform === 'win32') return;
const origSpawn = (Bun as any).spawn;
const origSpawnSync = (Bun as any).spawnSync;
const spawns: string[] = [];
@ -73,11 +79,16 @@ describe('process liveness probe (Windows terminal-agent leak)', () => {
}
});
test('4. no source file probes liveness via tasklist', () => {
// Static tripwire: re-introducing a tasklist-based existence check
// anywhere in src/ resurrects the false-negative class.
test('4. no source file probes liveness via tasklist outside the central helper', () => {
// Static tripwire: ad-hoc tasklist existence checks scattered across src/
// resurrect the false-negative class (each call site re-invents the
// timeout/parse handling and gets it subtly wrong). The ONE sanctioned
// site is error-handling.ts's isProcessAlive win32 branch — centralized,
// windowsHide, bounded timeout, quoted-CSV `"${pid}"` match. Every other
// file must route through the helper.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
if (file === 'error-handling.ts') continue; // the canonical helper
const code = stripComments(content);
// `PID eq` is the existence-probe form specifically. Other tasklist
// uses (e.g. IMAGENAME filters for browser detection) are unaffected.

View File

@ -0,0 +1,53 @@
/**
* #2242 bug 1 regression tripwire: never mutate the signed Chrome-for-Testing
* bundle.
*
* The old launchHeaded() "rebrand" ran a global
* `.replace(/Google Chrome for Testing/g, 'GStack Browser')` over the
* bundle's Info.plist which renamed CFBundleExecutable to a binary that
* doesn't exist and overwrote Resources/*.icns. Both writes broke the
* codesign seal: GPU process exit_code=5, headed mode dead on macOS 26
* (#2242, #2138, #2139).
*
* Static invariant (same pattern as cdp-session-cleanup.test.ts): the
* browser lifecycle code must contain NO write into the Chromium .app
* bundle. Branding lives in the wrapper .app / custom GBrowser build.
* These assertions fail on the pre-fix code.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const SRC = fs.readFileSync(
path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'),
'utf-8',
);
describe('#2242: signed Chromium bundle is never mutated', () => {
test('no global Google-Chrome-for-Testing plist replace', () => {
expect(SRC).not.toContain("replace(/Google Chrome for Testing/g");
});
test('no Info.plist write into the Chromium bundle (reads allowed: self-heal probe)', () => {
// The old code built `Info.plist` under the bundle's Contents dir and
// wrote it back. Any reappearance of a WRITE is a regression. The
// launch-time self-heal legitimately READS the plist to detect bundles
// the old code already poisoned (EV4), so the path construction itself
// is allowed — writes into it are not.
expect(SRC).not.toMatch(/writeFileSync\(\s*chromePlist/);
const plistWrites = SRC.match(/writeFileSync\([^)]*[Pp]list/g) || [];
expect(plistWrites).toEqual([]);
});
test('no icon overwrite into the Chromium bundle Resources dir', () => {
expect(SRC).not.toMatch(/copyFileSync\([^)]*destIcon/);
expect(SRC).not.toContain("CFBundleIconFile");
});
test('the tombstone comment documenting why stays put', () => {
// If someone deletes the explanation, the next contributor reintroduces
// the mutation in good faith. Keep the why next to the where.
expect(SRC).toContain('#2242');
});
});

View File

@ -0,0 +1,123 @@
/**
* acquireServerLock error honesty (#1084 regression).
*
* The old code wrapped fs.openSync(lockPath, 'wx') in a bare `catch {}`
* EVERY errno (EACCES, EIO, ENOSPC, ENOENT) fell into the "lock already
* held" path and surfaced as "another instance is starting the server",
* a phantom 15s contention timeout that masked the real filesystem error.
*
* New contract:
* - EEXIST + live holder null (real contention)
* - EEXIST + dead holder stale lock removed, acquired
* - ENOENT (dir missing) create dir, retry once, acquired
* - anything else ServerLockError with the real errno
*/
import { describe, test, expect, afterAll } from 'bun:test';
import * as fs from 'fs';
// Default (CJS) export — its properties are mutable in Bun, unlike the frozen
// `* as fs` namespace, and mutations propagate to cli.ts's own fs import.
// Used only for the depth-cap livelock simulations below (restored in finally).
import fsMutable from 'fs';
import * as os from 'os';
import * as path from 'path';
import { acquireServerLock, ServerLockError } from '../src/cli';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-lock-'));
afterAll(() => {
// Restore write perm so cleanup can delete the read-only dir.
try { fs.chmodSync(path.join(tmpRoot, 'rodir'), 0o700); } catch {}
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
describe('acquireServerLock (#1084 error honesty)', () => {
test('happy path: acquires and releases', () => {
const lockPath = path.join(tmpRoot, 'happy.lock');
const release = acquireServerLock(lockPath);
expect(release).not.toBeNull();
expect(fs.readFileSync(lockPath, 'utf8').trim()).toBe(String(process.pid));
release!();
expect(fs.existsSync(lockPath)).toBe(false);
});
test('EACCES throws ServerLockError with the real errno — NOT phantom contention', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
const rodir = path.join(tmpRoot, 'rodir');
fs.mkdirSync(rodir, { recursive: true });
fs.chmodSync(rodir, 0o500); // r-x: open('wx') inside fails EACCES
const lockPath = path.join(rodir, 'browse.json.lock');
let thrown: any = null;
try {
acquireServerLock(lockPath); // old code: returned null (phantom contention)
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ServerLockError);
expect(thrown.code).toBe('EACCES');
expect(thrown.message).toContain('E_SERVER_LOCK (EACCES)');
expect(thrown.message).toContain(lockPath);
});
test('ENOENT (missing lock dir) creates the dir and acquires', () => {
const lockPath = path.join(tmpRoot, 'newdir', 'browse.json.lock');
// old code: openSync ENOENT → bare catch → readFileSync ENOENT → null
const release = acquireServerLock(lockPath);
expect(release).not.toBeNull();
expect(fs.existsSync(lockPath)).toBe(true);
release!();
});
test('EEXIST + dead holder: removes stale lock and acquires', () => {
const lockPath = path.join(tmpRoot, 'stale.lock');
fs.writeFileSync(lockPath, '999999999\n'); // PID that cannot be alive
const release = acquireServerLock(lockPath);
expect(release).not.toBeNull();
release!();
});
test('EEXIST + live holder: returns null (real contention, no throw)', () => {
const lockPath = path.join(tmpRoot, 'live.lock');
fs.writeFileSync(lockPath, `${process.pid}\n`); // this test process is alive
expect(acquireServerLock(lockPath)).toBeNull();
fs.unlinkSync(lockPath);
});
test('EEXIST + garbage lockfile content: NaN pid is treated as stale, lock acquired', () => {
const lockPath = path.join(tmpRoot, 'garbage.lock');
fs.writeFileSync(lockPath, 'not-a-pid\n'); // parseInt → NaN → falsy → stale path
const release = acquireServerLock(lockPath);
expect(release).not.toBeNull();
// Our pid replaced the garbage — the stale lock was removed and re-acquired.
expect(fs.readFileSync(lockPath, 'utf8').trim()).toBe(String(process.pid));
release!();
expect(fs.existsSync(lockPath)).toBe(false);
});
// NOTE: the "stale lock that survives unlink" livelock variant is deliberately
// not simulated here — the source removes locks through safeUnlink's own fs
// binding, which a test-side fs monkey-patch cannot reliably intercept in Bun.
// The depth cap itself is exercised by the vanish-race test below.
test('depth cap: holder that vanishes between open and read returns null after 5 retries', () => {
// The EEXIST → readFileSync ENOENT race: the lock exists at openSync but
// is gone by the read (holder released in between). Repeated forever
// (open/release storm), the same depth cap must bound the retry loop.
const lockPath = path.join(tmpRoot, 'vanish.lock');
fs.writeFileSync(lockPath, `${process.pid}\n`);
const origRead = fsMutable.readFileSync;
try {
(fsMutable as any).readFileSync = (p: fs.PathLike | number, ...rest: unknown[]) => {
if (p === lockPath) {
const e: NodeJS.ErrnoException = new Error('mock: lock vanished before read');
e.code = 'ENOENT';
throw e;
}
return (origRead as any)(p, ...rest);
};
expect(acquireServerLock(lockPath)).toBeNull();
} finally {
(fsMutable as any).readFileSync = origRead;
fs.unlinkSync(lockPath);
}
});
});

View File

@ -0,0 +1,268 @@
/**
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
*
* Pins the leg of the browser-lifecycle contract that had no coverage:
* "shut down without losing live session state." Pre-fix, the headless
* daemon used non-persistent chromium.launch() with zero storage
* persistence any crash or binary-version auto-restart silently lost all
* auth. These tests fail on the old tree (module absent, no wiring).
*
* Suites:
* 1. Pure serialize/deserialize/filter units (free, instant).
* 2. Real-Chromium round-trip: cookie + localStorage survive a full
* manager teardown + relaunch via persist/restore.
* 3. Static wiring tripwire: server.ts restores at launch, snapshots at
* shutdown, and the gate is BROWSE_PERSIST_STATE (default off).
*/
import { describe, test, expect, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
serializeSessionState, deserializeSessionState, filterSessionCookies,
isSessionPersistEnabled, persistSessionState, restoreSessionState,
} from '../src/session-persist';
import type { BrowserState } from '../src/browser-manager';
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-persist-'));
afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); });
describe('session-persist units', () => {
test('config gate: default off, exactly "1" enables', () => {
expect(isSessionPersistEnabled({} as NodeJS.ProcessEnv)).toBe(false);
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '0' } as any)).toBe(false);
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: 'true' } as any)).toBe(false);
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '1' } as any)).toBe(true);
});
test('serialize strips loadedHtml/owner, keeps cookies + storage', () => {
const state: BrowserState = {
cookies: [{ name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'Lax' } as any],
pages: [{
url: 'https://example.com/app',
isActive: true,
storage: { localStorage: { k: 'v' }, sessionStorage: {} },
loadedHtml: '<script>evil</script>',
loadedHtmlWaitUntil: 'load',
owner: 'agent-1',
}],
};
const raw = serializeSessionState(state);
expect(raw).not.toContain('loadedHtml');
expect(raw).not.toContain('evil');
expect(raw).not.toContain('owner');
const parsed = JSON.parse(raw);
expect(parsed.version).toBe(1);
expect(parsed.cookies[0].name).toBe('sid');
expect(parsed.pages[0].storage.localStorage.k).toBe('v');
});
test('deserialize rejects corrupt JSON, wrong version, missing arrays', () => {
expect(deserializeSessionState('not json{')).toBeNull();
expect(deserializeSessionState('{"version":99,"cookies":[],"pages":[]}')).toBeNull();
expect(deserializeSessionState('{"version":1,"cookies":{}}')).toBeNull();
});
test('deserialize strips loadedHtml/owner even if tampered onto disk', () => {
const raw = JSON.stringify({
version: 1,
cookies: [],
pages: [{ url: 'https://x.com', isActive: true, storage: null, loadedHtml: '<h1>x</h1>', owner: 'evil' }],
});
const state = deserializeSessionState(raw)!;
expect((state.pages[0] as any).loadedHtml).toBeUndefined();
expect((state.pages[0] as any).owner).toBeUndefined();
});
test('cookie filter drops malformed + internal-network domains', () => {
const kept = filterSessionCookies([
{ name: 'ok', value: 'v', domain: 'example.com' },
{ name: 'ok2', value: 'v', domain: '.example.com' }, // leading-dot public domain kept
{ name: 'bad1', value: 'v', domain: 'localhost' },
{ name: 'bad2', value: 'v', domain: '.corp.internal' },
{ name: 'bad3', value: 'v', domain: '169.254.169.254' },
{ name: 'bad4', value: 'v', domain: '169.254.1.2' }, // whole link-local block, not just metadata
{ name: 'bad5', value: 'v', domain: '127.0.0.1' }, // IPv4 loopback literal
{ name: 'bad6', value: 'v', domain: '.127.0.0.1' }, // leading-dot loopback variant
{ name: 'bad7', value: 'v', domain: '::1' }, // IPv6 loopback
{ name: 'bad8', value: 'v', domain: '[::1]' }, // bracketed IPv6 loopback
{ name: 'bad9', value: 42, domain: 'example.com' },
null,
]);
expect(kept.map((c: any) => c.name)).toEqual(['ok', 'ok2']);
});
test('restoreSessionState: missing file → null, corrupt file → quarantined to .corrupt', async () => {
const bmNeverCalled = { closeAllPages() { throw new Error('must not restore'); } } as any;
expect(await restoreSessionState(bmNeverCalled, path.join(tmpRoot, 'nope.json'))).toBeNull();
const corrupt = path.join(tmpRoot, 'corrupt.json');
fs.writeFileSync(corrupt, '{oops');
expect(await restoreSessionState(bmNeverCalled, corrupt)).toBeNull();
expect(fs.existsSync(corrupt)).toBe(false); // moved aside, won't block every future launch
expect(fs.existsSync(`${corrupt}.corrupt`)).toBe(true); // forensic artifact kept (R3)
});
test('persistSessionState is a no-op in headed mode (profile owns state)', async () => {
const file = path.join(tmpRoot, 'headed.json');
const bm = {
getConnectionMode: () => 'headed',
saveState() { throw new Error('must not snapshot headed session'); },
} as any;
await persistSessionState(bm, file);
expect(fs.existsSync(file)).toBe(false);
});
test('persist writes atomically: no .tmp left behind, file parses', async () => {
const file = path.join(tmpRoot, 'atomic.json');
const state: BrowserState = {
cookies: [{ name: 'sid', value: 'abc', domain: 'example.com' } as any],
pages: [{ url: 'https://example.com', isActive: true, storage: null }],
};
const bm = { getConnectionMode: () => 'launched', saveState: async () => state } as any;
await persistSessionState(bm, file);
expect(fs.existsSync(`${file}.tmp`)).toBe(false); // staged copy renamed away
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(parsed.cookies[0].name).toBe('sid');
});
test('a failed snapshot write preserves the previous good snapshot', async () => {
// chmod-based read-only dirs don't bind on Windows or when running as root.
if (process.platform === 'win32' || process.getuid?.() === 0) return;
const dir = path.join(tmpRoot, 'ro');
fs.mkdirSync(dir);
const file = path.join(dir, 'session-state.json');
const goodState: BrowserState = {
cookies: [],
pages: [{ url: 'https://good.example', isActive: true, storage: null }],
};
const bm = { getConnectionMode: () => 'launched', saveState: async () => goodState } as any;
await persistSessionState(bm, file);
fs.chmodSync(dir, 0o500); // next .tmp write throws EACCES mid-persist
try {
await expect(persistSessionState(bm, file)).rejects.toThrow();
// The crash-mid-write scenario the feature exists to survive: the
// previous good snapshot is untouched and still parses.
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(parsed.pages[0].url).toBe('https://good.example');
} finally {
fs.chmodSync(dir, 0o700);
}
});
});
describe('session-persist round-trip (real Chromium)', () => {
test('cookie + localStorage + URL survive teardown → relaunch; loopback cookies dropped', async () => {
const { BrowserManager } = await import('../src/browser-manager');
const { startTestServer } = await import('./test-server');
const { server, url } = startTestServer(0);
const stateFile = path.join(tmpRoot, 'roundtrip.json');
const bm1 = new BrowserManager();
await bm1.launch();
try {
const page = bm1.getPage();
await page.goto(`${url}/basic.html`, { waitUntil: 'domcontentloaded' });
// Real-site cookie: set on the context for a non-loopback domain (the
// restore hygiene filter deliberately drops loopback/link-local
// domains, so a 127.0.0.1 test-server cookie can't stand in for it).
await page.context().addCookies([
{ name: 'session_marker', value: 'alive-after-restart', domain: 'example.com', path: '/' },
]);
await page.evaluate(() => {
document.cookie = 'loopback_marker=must-be-dropped; path=/'; // 127.0.0.1 host cookie
localStorage.setItem('auth_marker', 'still-logged-in');
});
await persistSessionState(bm1, stateFile);
} finally {
await bm1.close();
}
// File on disk is owner-only (cookies are secrets).
if (process.platform !== 'win32') {
expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600);
}
const bm2 = new BrowserManager();
await bm2.launch();
try {
const restored = await restoreSessionState(bm2, stateFile);
expect(restored).not.toBeNull();
expect(restored!.pages.length).toBe(1); // counts derivable without a saveState() round-trip
// Hygiene filter applied at restore: the real-site cookie survives,
// the loopback cookie does not.
expect(restored!.cookies.map((c: any) => c.name)).toEqual(['session_marker']);
const page = bm2.getPage();
expect(page.url()).toContain('/basic.html');
const marker = await page.evaluate(() => ({
cookie: document.cookie,
auth: localStorage.getItem('auth_marker'),
}));
expect(marker.cookie).not.toContain('loopback_marker'); // dropped by isInternalCookieDomain
expect(marker.auth).toBe('still-logged-in');
const restoredCookies = await page.context().cookies('https://example.com');
expect(restoredCookies.map((c) => `${c.name}=${c.value}`)).toContain('session_marker=alive-after-restart');
} finally {
await bm2.close();
server.stop(true);
}
}, 60_000);
});
describe('server wiring (static tripwire)', () => {
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'src', 'server.ts'), 'utf-8');
test('start() restores and schedules interval snapshots behind the gate', () => {
expect(SERVER_SRC).toContain('isSessionPersistEnabled()');
expect(SERVER_SRC).toContain('restoreSessionState(browserManager');
expect(SERVER_SRC).toContain('sessionPersistIntervalMs()');
});
test('start() restores in the background AFTER the port binds (CLI readiness must not wait)', () => {
// Restore re-creates tabs with up-to-15s goto timeouts; the CLI gives up
// at 8s. A restore that runs before Bun.serve() makes every $B command
// report "Server failed to start" on one slow saved URL.
const serveAt = SERVER_SRC.indexOf('const server = Bun.serve(');
const restoreAt = SERVER_SRC.indexOf('restoreSessionState(browserManager');
expect(serveAt).toBeGreaterThan(-1);
expect(restoreAt).toBeGreaterThan(serveAt);
});
test('interval snapshots carry an in-flight guard (no overlapping persists)', () => {
expect(SERVER_SRC).toContain('persistInFlight');
});
test('interval ticks are gated on isShuttingDown (belt half of the shutdown ordering fix)', () => {
// A tick that fires during browser teardown snapshots a degraded state
// (zero tabs) over the good final snapshot. The handle-clear in shutdown()
// is the suspenders; this gate is the belt for a tick already scheduled.
const tickerAt = SERVER_SRC.indexOf('sessionPersistInterval = setInterval(');
expect(tickerAt).toBeGreaterThan(-1);
const tickerBlock = SERVER_SRC.slice(tickerAt, tickerAt + 500);
expect(tickerBlock).toContain('if (isShuttingDown) return;');
});
test('shutdown() clears the persist ticker BEFORE the final snapshot (suspenders half)', () => {
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
const clearAt = SERVER_SRC.indexOf('clearInterval(sessionPersistInterval)', shutdownStart);
const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart);
expect(clearAt).toBeGreaterThan(shutdownStart);
expect(persistAt).toBeGreaterThan(clearAt);
});
test('shutdown() takes a final snapshot BEFORE closing the browser', () => {
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart);
const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart);
expect(persistAt).toBeGreaterThan(shutdownStart);
expect(closeAt).toBeGreaterThan(persistAt);
});
test('shutdown() snapshot is deadlined — a wedged page.evaluate cannot hang shutdown', () => {
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart);
const raceAt = SERVER_SRC.indexOf('Promise.race', shutdownStart);
expect(raceAt).toBeGreaterThan(shutdownStart);
expect(raceAt).toBeLessThan(closeAt);
});
});

View File

@ -14,7 +14,7 @@ import { handleMetaCommand } from '../src/meta-commands';
import * as fs from 'fs';
const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleReadCommand(cmd, args, b.getActiveSession());
_handleReadCommand(cmd, args, b.getActiveSession(), b);
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
_handleWriteCommand(cmd, args, b.getActiveSession(), b);

View File

@ -0,0 +1,23 @@
import { describe, expect, test } from 'bun:test';
import { handleMetaCommand } from '../src/meta-commands';
describe('server control acknowledgement ordering', () => {
for (const [command, acknowledgement] of [
['stop', 'Server stopped'],
['restart', 'Restarting...'],
] as const) {
test(`${command} acknowledges before closing the listener`, async () => {
let shutdownCalls = 0;
const manager = { getActiveSession: () => ({}) } as any;
const result = await handleMetaCommand(command, [], manager, async () => {
shutdownCalls += 1;
});
expect(result).toBe(acknowledgement);
expect(shutdownCalls).toBe(0);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(shutdownCalls).toBe(1);
});
}
});

View File

@ -0,0 +1,175 @@
/**
* Telemetry consent tiers the user-consent enforcement point.
*
* Telemetry is OPT-IN: it emits only when the user granted a tier through
* the consent prompt (`telemetry: community` or `telemetry: anonymous` in
* ~/.gstack/config.yaml). An absent key, an absent config file, an explicit
* `off`, or any unrecognized value all mean DISABLED the same default
* bin/gstack-config's DEFAULTS table reports for an unset key, so a daemon
* spawned outside a skill preamble (direct $B use, embedders) can never
* emit while `gstack-config get telemetry` tells the user 'off'.
*
* The persistent tier reads through the shared flat-YAML helper in
* config.ts (readGstackConfigYamlKey), same parser as the pair-agent gate.
* Env tier: GSTACK_TELEMETRY_OFF=1 always disables; =0 is a harness-side
* consent assertion that covers the no-config default only it never
* overrides an explicit `telemetry: off`.
*
* Harness mirrors pair-agent-optin-gate.test.ts: GSTACK_HOME temp dir,
* env saved/restored per test, cache reset via _resetTelemetryCache.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isTelemetryDisabled, logTelemetry, _resetTelemetryCache } from '../src/telemetry';
const savedEnv = {
GSTACK_HOME: process.env.GSTACK_HOME,
GSTACK_TELEMETRY_OFF: process.env.GSTACK_TELEMETRY_OFF,
};
const tmpHomes: string[] = [];
/** Fresh GSTACK_HOME with the given config.yaml body (null = no file). */
function tmpHomeWith(configYaml: string | null): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-telemetry-optout-'));
tmpHomes.push(dir);
if (configYaml !== null) {
fs.writeFileSync(path.join(dir, 'config.yaml'), configYaml);
}
process.env.GSTACK_HOME = dir;
delete process.env.GSTACK_TELEMETRY_OFF;
_resetTelemetryCache();
return dir;
}
afterEach(() => {
for (const k of ['GSTACK_HOME', 'GSTACK_TELEMETRY_OFF'] as const) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k]!;
}
_resetTelemetryCache();
while (tmpHomes.length) fs.rmSync(tmpHomes.pop()!, { recursive: true, force: true });
});
describe('telemetry persistent opt-out tier (config.yaml)', () => {
test('DISABLED when config.yaml has plain `telemetry: off`', () => {
tmpHomeWith('telemetry: off\n');
expect(isTelemetryDisabled()).toBe(true);
});
test("DISABLED when the value is single-quoted: telemetry: 'off'", () => {
tmpHomeWith("telemetry: 'off'\n");
expect(isTelemetryDisabled()).toBe(true);
});
test('DISABLED when the value is double-quoted: telemetry: "off"', () => {
tmpHomeWith('telemetry: "off"\n');
expect(isTelemetryDisabled()).toBe(true);
});
test('DISABLED with a trailing comment: telemetry: off # user opted out', () => {
tmpHomeWith('telemetry: off # user opted out\n');
expect(isTelemetryDisabled()).toBe(true);
});
test('DISABLED when the key sits among other keys', () => {
tmpHomeWith('pair_agent: off\ntelemetry: off\nskill_prefix: none\n');
expect(isTelemetryDisabled()).toBe(true);
});
test('ENABLED when the user granted the `anonymous` tier', () => {
tmpHomeWith('telemetry: anonymous\n');
expect(isTelemetryDisabled()).toBe(false);
});
test('ENABLED when the user granted the `community` tier', () => {
tmpHomeWith('telemetry: community\n');
expect(isTelemetryDisabled()).toBe(false);
});
test('DISABLED when the key is absent — consent was never granted', () => {
// bin/gstack-config's DEFAULTS table reports 'off' for an unset telemetry
// key; the daemon must agree or direct-$B spawns emit while the user is
// told telemetry is off (default-polarity split-brain).
tmpHomeWith('pair_agent: on\n');
expect(isTelemetryDisabled()).toBe(true);
});
test('DISABLED when config.yaml does not exist — fresh installs emit nothing', () => {
tmpHomeWith(null);
expect(isTelemetryDisabled()).toBe(true);
});
test('DISABLED on an unrecognized tier value (fail-closed)', () => {
tmpHomeWith('telemetry: banana\n');
expect(isTelemetryDisabled()).toBe(true);
});
test('a commented-out consent line does not enable: `# telemetry: community`', () => {
tmpHomeWith('# telemetry: community\n');
expect(isTelemetryDisabled()).toBe(true);
});
});
describe('telemetry env tier + cache semantics', () => {
test('GSTACK_TELEMETRY_OFF=1 disables even when config says anonymous', () => {
tmpHomeWith('telemetry: anonymous\n');
process.env.GSTACK_TELEMETRY_OFF = '1';
_resetTelemetryCache();
expect(isTelemetryDisabled()).toBe(true);
});
test('GSTACK_TELEMETRY_OFF=0 never overrides an explicit `telemetry: off`', () => {
// The =0 hint is a harness-side consent assertion for scratch homes with
// no config store; a user's written opt-out always wins over it.
tmpHomeWith('telemetry: off\n');
process.env.GSTACK_TELEMETRY_OFF = '0';
_resetTelemetryCache();
expect(isTelemetryDisabled()).toBe(true);
});
test('GSTACK_TELEMETRY_OFF=0 enables when no config store exists (harness seam)', () => {
tmpHomeWith(null);
process.env.GSTACK_TELEMETRY_OFF = '0';
_resetTelemetryCache();
expect(isTelemetryDisabled()).toBe(false);
});
test('verdict is cached per process; _resetTelemetryCache re-reads config', () => {
const dir = tmpHomeWith('telemetry: anonymous\n');
expect(isTelemetryDisabled()).toBe(false);
// Opt out on disk mid-process: the cached verdict holds until reset.
fs.writeFileSync(path.join(dir, 'config.yaml'), 'telemetry: off\n');
expect(isTelemetryDisabled()).toBe(false);
_resetTelemetryCache();
expect(isTelemetryDisabled()).toBe(true);
});
});
describe('enforcement: logTelemetry writes only with granted consent', () => {
test('config-tier opt-out suppresses the JSONL append', async () => {
const dir = tmpHomeWith('telemetry: off\n');
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
// Fire-and-forget path: give any (incorrect) async append time to land.
await new Promise((r) => setTimeout(r, 30));
expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false);
});
test('no consent ever recorded (absent key) suppresses the JSONL append', async () => {
const dir = tmpHomeWith('pair_agent: on\n');
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
await new Promise((r) => setTimeout(r, 30));
expect(fs.existsSync(path.join(dir, 'analytics', 'browse-telemetry.jsonl'))).toBe(false);
});
test('granted `community` tier appends the event', async () => {
const dir = tmpHomeWith('telemetry: community\n');
logTelemetry({ event: 'domain_skill_fired', host: 'example.com' });
await new Promise((r) => setTimeout(r, 30));
const file = path.join(dir, 'analytics', 'browse-telemetry.jsonl');
expect(fs.existsSync(file)).toBe(true);
expect(fs.readFileSync(file, 'utf-8')).toContain('domain_skill_fired');
});
});

View File

@ -1,8 +1,12 @@
import { describe, test, expect, afterEach } from 'bun:test';
import { describe, test, expect, afterEach, beforeEach, mock } from 'bun:test';
import { spawn, type Subprocess } from 'bun';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import * as crypto from 'crypto';
import { buildFetchHandler, __testInternals__, type ServerConfig } from '../src/server';
import { __resetRegistry } from '../src/token-registry';
import { resolveConfig } from '../src/config';
// End-to-end regression tests for the parent-process watchdog in server.ts.
// The watchdog has layered behavior since v0.18.1.0 (#1025) and v0.18.2.0
@ -18,16 +22,13 @@ import * as os from 'os';
// eventual cleanup.
//
// Tunnel mode coverage (parent dies → shutdown because idle timeout doesn't
// apply) is not covered by an automated test here — tunnelActive is a runtime
// variable set by /pair-agent's tunnel-create flow, not an env var, so faking
// it would require invasive test-only hooks. The mode check is documented
// inline at the watchdog and SIGTERM handlers, and would regress visibly for
// /pair-agent users (server lingers after disconnect).
// apply) is covered behaviorally in the in-process suite at the bottom of this
// file: the tick is exported via __testInternals__.parentWatchdogTick (same
// seam as idleCheckTick) and tunnelActive is simulated via setTunnelActive.
//
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
// stdout log line (fast). Test 3 shrinks the watchdog tick to 250ms via
// BROWSE_WATCHDOG_INTERVAL_MS and waits for the stay-alive log line, then
// confirms the server survived parent death (~1-2s instead of a 20s sleep).
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
// the server REMAINS alive after parent death (slow — ~20s observation window).
const ROOT = path.resolve(import.meta.dir, '..');
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
@ -138,26 +139,181 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
const parentPid = parentProc.pid!;
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
// 250ms tick (test-only knob) so this test doesn't wait out the
// production 15s interval; the old version blind-slept 22s.
serverProc = spawnServer(
{ BROWSE_PARENT_PID: String(parentPid), BROWSE_WATCHDOG_INTERVAL_MS: '250' },
34903,
);
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
const serverPid = serverProc.pid!;
// Give the server a beat to register the watchdog interval.
await Bun.sleep(500);
// Give the server a moment to start and register the watchdog interval.
await Bun.sleep(2000);
expect(isProcessAlive(serverPid)).toBe(true);
// Kill the parent. Pre-#994 the server would shut down on the next tick.
// Post-#994 it logs the parent exit and stays alive — wait for that log
// line instead of sleeping past a fixed interval.
// Kill the parent. The watchdog polls every 15s, so first tick after
// parent death lands within ~15s. Pre-#994 the server would shutdown
// here. Post-#994 the server logs the parent exit and stays alive.
parentProc.kill('SIGKILL');
const out = await readStdoutUntil(serverProc, 'server stays alive', 10_000);
expect(out).toContain(
`Parent process ${parentPid} exited (server stays alive, idle timeout will clean up)`,
);
// Wait long enough for at least one watchdog tick (15s) plus margin.
// Server should still be alive — that's the whole point of #994.
await Bun.sleep(20_000);
expect(isProcessAlive(serverPid)).toBe(true);
}, 30_000);
}, 45_000);
});
// The three tests above all fix the mode via env at SPAWN time, so none of them
// reaches the headed branch of the watchdog. That branch is only reachable by a
// RUNTIME promotion, which `handoff` performs: it swaps in a headed context on a
// running daemon without a restart, moving a daemon that legitimately registered
// a watchdog onto the fatal side of the check. The parent is usually a
// short-lived shell (Claude Code's Bash tool kills one after every invocation),
// so the next poll shut the daemon down and discarded whatever the user had been
// handed off to do — observed as repeated session loss mid-login.
//
// The fix must NOT clear the interval, though: the same tick is the
// tunnel-orphan reaper (idle timeout is disabled in tunnel mode, so parent
// death is the ONLY thing that reaps an internet-exposed daemon). Promotion
// sets a suppress flag the tick re-reads each pass — "being headed" no longer
// kills the daemon on parent death, but an active tunnel still does.
//
// Driving a real `handoff` needs a headed Chromium, which does not belong in the
// free tier, so this pins the WIRING instead — the same static-tripwire approach
// used by cdp-session-cleanup.test.ts and server-auth.test.ts. If either half of
// the contract is dropped, the crash returns silently and these fail. The
// behavioral halves (suppression + tunnel reaping) run in-process below.
describe('headed parent-death shutdown is suppressed on runtime promotion', () => {
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf-8');
test('handoff() notifies the server that it promoted the daemon', () => {
const src = read('src/browser-manager.ts');
const promote = src.indexOf("this.connectionMode = 'headed';", src.indexOf('async handoff('));
expect(promote).toBeGreaterThan(-1);
// The notification must follow the promotion closely; a call left far away
// (or removed) is the regression this guards.
expect(src.slice(promote, promote + 800)).toContain('this.onHeadedPromotion?.()');
});
test('the server binds that callback to the suppress-flag setter', () => {
const src = read('src/server.ts');
expect(src).toContain('function suppressHeadedParentShutdown()');
// Bound on BOTH the module-level manager and any embedder-supplied one; the
// watchdog reads activeBrowserManager, so binding only the default instance
// leaves embedders (e.g. gbrowser) promoting silently.
expect(src).toContain('browserManager.onHeadedPromotion = suppressHeadedParentShutdown');
expect(src).toContain('cfgBrowserManager.onHeadedPromotion = suppressHeadedParentShutdown');
});
test('promotion must NOT clear the interval — the tick doubles as the tunnel-orphan reaper', () => {
const src = read('src/server.ts');
// The original #2565 absorption cleared the ENTIRE interval on promotion.
// Sequence handoff → resume → /pair-agent tunnel then left an
// internet-exposed daemon that nothing reaps. The tick must stay
// registered and re-check the suppress flag + tunnelActive every pass.
expect(src).not.toContain('clearInterval(parentWatchdogTimer)');
expect(src).toContain('setInterval(parentWatchdogTick');
const tickStart = src.indexOf('function parentWatchdogTick(');
expect(tickStart).toBeGreaterThan(-1);
const tick = src.slice(tickStart, src.indexOf('\n}', tickStart));
expect(tick).toContain('headedParentShutdownSuppressed');
expect(tick).toContain('tunnelActive');
});
});
// ─── Behavioral: suppressed watchdog still reaps tunnel orphans ────────────
//
// In-process, via the same __testInternals__ seam server-factory.test.ts uses
// for idleCheckTick. parentWatchdogTick(deadPid) simulates the 15s poll
// discovering a dead parent; setTunnelActive simulates /pair-agent's
// tunnel-create flow; suppressHeadedParentShutdown is exactly what the
// handoff promotion callback invokes.
function makeMinimalConfig(mode: 'launched' | 'headed', tmpDir: string): ServerConfig {
const base = resolveConfig();
return {
authToken: 'watchdog-test-' + crypto.randomBytes(16).toString('hex'),
browsePort: 34567,
idleTimeoutMs: 1_800_000,
// State paths pointed at a scratch dir so shutdown()'s cleanup can never
// touch a real daemon's files on the machine running the tests.
config: { ...base, stateFile: path.join(tmpDir, 'browse-state.json'), stateDir: tmpDir },
browserManager: {
getConnectionMode: () => mode,
isWatching: () => false,
stopWatch: () => {},
close: async () => {},
onDisconnect: null,
} as any,
startTime: Date.now(),
// Skip terminal-agent teardown: identity files live under the REAL state
// dir conventions and this suite must stay hermetic.
ownsTerminalAgent: false,
};
}
describe('suppressed watchdog still reaps tunnel orphans (behavioral)', () => {
// A PID above darwin/linux default pid_max: process.kill(pid, 0) throws
// ESRCH, which the tick reads as "parent exited".
const DEAD_PID = 999_999;
let scratch: string;
const savedChromiumProfile = process.env.CHROMIUM_PROFILE;
beforeEach(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-tick-'));
// shutdown() runs cleanSingletonLocks(resolveChromiumProfile()); point it
// at scratch so the operator's real profile is never inspected.
process.env.CHROMIUM_PROFILE = path.join(scratch, 'chromium-profile');
__resetRegistry();
__testInternals__.setTunnelActive(false);
__testInternals__.setLastActivity(Date.now());
__testInternals__.resetShutdownState();
__testInternals__.resetParentWatchdogState();
});
afterEach(() => {
if (savedChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = savedChromiumProfile;
__testInternals__.setTunnelActive(false);
__testInternals__.resetShutdownState();
__testInternals__.resetParentWatchdogState();
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch {}
});
// Drain the fire-and-forget shutdown promise chain (flushBuffers + close)
// the same way server-factory.test.ts does before asserting on exit.
async function drainShutdown(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await new Promise<void>((r) => setImmediate(r));
await new Promise<void>((r) => setImmediate(r));
}
test('after promotion suppression, parent death does NOT shut down a headed daemon (#2565)', async () => {
const exitMock = mock((_code?: number) => {});
const originalExit = process.exit;
(process as any).exit = exitMock;
try {
buildFetchHandler(makeMinimalConfig('headed', scratch));
__testInternals__.suppressHeadedParentShutdown(); // what handoff promotion triggers
__testInternals__.parentWatchdogTick(DEAD_PID);
await drainShutdown();
expect(exitMock).not.toHaveBeenCalled();
} finally {
(process as any).exit = originalExit;
}
});
test('CRITICAL: suppression active + tunnel live — parent death still shuts down', async () => {
const exitMock = mock((_code?: number) => {});
const originalExit = process.exit;
(process as any).exit = exitMock;
try {
buildFetchHandler(makeMinimalConfig('headed', scratch));
__testInternals__.suppressHeadedParentShutdown();
__testInternals__.setTunnelActive(true); // handoff → resume → /pair-agent tunnel
__testInternals__.parentWatchdogTick(DEAD_PID);
await drainShutdown();
// The tick is the ONLY reaper for tunnel orphans (idle timeout is
// disabled in tunnel mode). If this fails, an internet-exposed daemon
// outlives its parent forever.
expect(exitMock).toHaveBeenCalled();
} finally {
(process as any).exit = originalExit;
}
});
});

View File

@ -0,0 +1,64 @@
/**
* Static tripwire for #1835: child spawns reachable on Windows must pass
* windowsHide, or every daemon relaunch / taskkill / icacls / powershell
* invocation flashes a black console window (and can steal focus).
*
* Source-level, same style as server-auth.test.ts / cdp-session-cleanup.test.ts:
* cheap, deterministic, runs on every platform.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const SRC = (f: string) => fs.readFileSync(path.join(import.meta.dir, '../src', f), 'utf-8');
/** Every occurrence of `needle` in `src` must have `windowsHide` within the
* next `window` chars (the spawn's options object). */
function expectHideNearEvery(src: string, needle: string, window = 400): void {
let idx = src.indexOf(needle);
expect(idx).toBeGreaterThanOrEqual(0);
while (idx !== -1) {
const slice = src.slice(idx, idx + window);
expect(slice).toMatch(/windowsHide:\s*true/);
idx = src.indexOf(needle, idx + needle.length);
}
}
describe('windowsHide on Windows-reachable spawns (#1835)', () => {
test('daemon launch paths in cli.ts pass windowsHide', () => {
const cli = SRC('cli.ts');
// Installed path: node -e launcher — both the outer spawnSync and the
// inner detached daemon spawn (inside the launcher code string).
expect(cli).toContain('detached:true,windowsHide:true');
expectHideNearEvery(cli, "'-e', launcherCode]");
// Dev fallback: detached bun spawn.
expectHideNearEvery(cli, "nodeSpawn('bun'");
// taskkill (killServer).
expectHideNearEvery(cli, "'taskkill'");
});
test('Windows-only process probes pass windowsHide', () => {
// tasklist in isProcessAlive — runs in polling loops.
expectHideNearEvery(SRC('error-handling.ts'), "'tasklist'");
// powershell DPAPI + tasklist in cookie import.
const cookie = SRC('cookie-import-browser.ts');
expectHideNearEvery(cookie, "'powershell'");
expectHideNearEvery(cookie, "'tasklist'");
});
test('icacls calls in file-permissions.ts pass windowsHide', () => {
const perms = SRC('file-permissions.ts');
expect((perms.match(/'icacls'/g) || []).length).toBeGreaterThanOrEqual(3);
expectHideNearEvery(perms, "'icacls'");
});
test('terminal-agent respawn in terminal-agent-control.ts passes windowsHide', () => {
// The CLI cold-start + v1.44 watchdog respawn path. On Windows it runs
// through the Node polyfill (dist/bun-polyfill.cjs) whose host default is
// the opposite of Bun's — a visible console window on every watchdog
// respawn is the symptom when the flag is dropped. Wider window: the
// spawn's options object carries the full env wiring before the flag.
expectHideNearEvery(SRC('terminal-agent-control.ts'), '(Bun as any).spawn(', 700);
});
});

View File

@ -674,6 +674,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -677,6 +677,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -677,6 +677,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -700,6 +700,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -681,6 +681,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@ -899,41 +903,70 @@ If `NEEDS_SETUP`:
## Test Framework Bootstrap
**Detect existing test framework and project runtime:**
**Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5.
**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse.
```bash
setopt +o nomatch 2>/dev/null || true # zsh compat
# Detect project runtime
[ -f Gemfile ] && echo "RUNTIME:ruby"
# Definitive ecosystem markers (presence = ecosystem, NOT a command to run)
[ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py"
{ [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python"
[ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ] && echo "RUNTIME:ruby"
[ -f package.json ] && echo "RUNTIME:node"
[ -f requirements.txt ] || [ -f pyproject.toml ] && echo "RUNTIME:python"
[ -f go.mod ] && echo "RUNTIME:go"
[ -f Cargo.toml ] && echo "RUNTIME:rust"
[ -f composer.json ] && echo "RUNTIME:php"
[ -f mix.exs ] && echo "RUNTIME:elixir"
[ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven"
{ [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle"
# Detect sub-frameworks
[ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails"
[ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs"
# Check for existing test infrastructure
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null
ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null
# Existing test path — config files, declared scripts, AND test FILES.
# A project with real tests and no config file is the common miss.
ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null
[ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test"
[ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test"
[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest"
git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/'
# Rust keeps unit tests inside src/, so file names alone miss them
[ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source"
# Check opt-out marker
[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED"
```
**If test framework detected** (config files or test directories found):
Print "Test framework detected: {name} ({N} existing tests). Skipping bootstrap."
Map the markers to the command you will OFFER — never to one you run on a guess:
| Marker | Ecosystem | Candidate command to offer |
|--------|-----------|----------------------------|
| `manage.py` | Django | `python manage.py test` (or `pytest` when pytest-django is in the deps) |
| `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` |
| `go.mod` (+ any `*_test.go`) | Go | `go test ./...` |
| `Cargo.toml` | Rust | `cargo test` |
| `pom.xml` | JVM (Maven) | `mvn test` |
| `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` |
| `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` |
| `mix.exs` | Elixir | `mix test` |
| `composer.json` | PHP | `composer test` or `./vendor/bin/phpunit` |
| `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names |
| `Makefile` with a `test:` target | any | `make test` |
**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one.
Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns).
Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.**
Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `<app>/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate.
**If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.**
**If NO runtime detected** (no config files found): Use AskUserQuestion:
**If NO ecosystem marker matched:** Use AskUserQuestion:
"I couldn't detect your project's language. What runtime are you using?"
Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests.
If the runtime you need isn't listed, offer "Other" and take the runtime plus the test command as free text.
If user picks H → write `.gstack/no-test-bootstrap` and continue without tests.
**If runtime detected but no test framework — bootstrap:**
**If an ecosystem matched but there is no existing-test evidence at all — bootstrap:**
### B2. Research best practices
@ -949,7 +982,9 @@ If WebSearch is unavailable, use this built-in knowledge table:
| Node.js | vitest + @testing-library | jest + @testing-library |
| Next.js | vitest + @testing-library/react + playwright | jest + cypress |
| Python | pytest + pytest-cov | unittest |
| Django | pytest + pytest-django | Django's built-in `manage.py test` (unittest) |
| Go | stdlib testing + testify | stdlib only |
| JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only |
| Rust | cargo test (built-in) + mockall | — |
| PHP | phpunit + mockery | pest |
| Elixir | ExUnit (built-in) + ex_machina | — |

View File

@ -695,6 +695,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -0,0 +1,305 @@
# Code-Intelligence Provider Contract
Status: design + first implementation slice
Owner: maintainer-directed internal work
Related: `runtime/context.js` (Context.dev provider pattern),
`scripts/gstack2/browser-provider-contract.ts` (the existing provider-contract idiom),
`lib/gstack-decision-semantic.ts` (degrade-to-null reliability contract)
## Problem
gstack carries ~17k LOC of home-grown code-intelligence glue: transcript
ingestion (`bin/gstack-memory-ingest.ts`, ~1.9k), a unified sync verb
(`bin/gstack-gbrain-sync.ts`, ~1.6k), context loading
(`bin/gstack-brain-context-load.ts`), a three-tier planning cache
(`bin/gstack-brain-cache`), source reconciliation, engine-status classification,
destructive-op guards, plus ~15 `bin/gstack-gbrain-*` and `bin/gstack-brain-*`
entrypoints and ~40 tests. All of it is bespoke wiring around one external tool
(GBrain) reached by direct CLI shell-out.
We do not want to keep maintaining a home-grown indexer. We want gstack to
define a **small optional contract** that external providers implement, so the
indexing/search/graph work lives in the provider, not in gstack.
Hard requirement, non-negotiable: **gstack must remain fully functional with the
provider OFF.** File-only paths (the decision store, Context Recovery, grep) stay
reliable and never depend on a provider being present. This is the existing
decision-store philosophy (`lib/gstack-decision.ts` has zero gbrain imports;
`lib/gstack-decision-semantic.ts` degrades to `null`). The contract is an
enhancement, never a dependency.
## Design decision: repo-oriented, not document-store
Settled (do not relitigate). The contract is **repo-oriented**:
```
register_source(repo) — required
refresh(source) — required
search(query) — required
status(source) — required
```
`add` / `delete` / `export` are **optional capabilities** a provider MAY
advertise. GBrain advertises them (its native primitive is document-by-slug:
put/delete/get/export); code-search and code-graph tools decline them.
A document-store contract (add / delete-by-id / export as *required* ops) was
rejected: it misrepresents code-search and code-graph tools. Sourcebot indexes a
whole repo and exposes search; it has no concept of "delete document id X".
Forcing every provider to implement a document CRUD surface would either exclude
the exact tools we most want (whole-repo indexers, graph tools) or force them to
stub required ops with lies. Repo-in / query-out is the honest common
denominator. GBrain's document axis survives as an *optional* capability, not as
the contract's shape.
## The contract
TypeScript in `lib/code-intelligence/contract.ts`. Shape (abridged):
```ts
type CodeProviderCapability =
| "register_source" | "refresh" | "search" | "status" // required
| "add" | "delete" | "export"; // optional
interface CodeProvider {
readonly id: "gbrain" | "sourcebot" | "graphify";
readonly label: string;
readonly capabilities: ReadonlySet<CodeProviderCapability>;
readonly local: boolean; // true = no repo content leaves the machine
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
}
```
Every provider MUST implement the four required methods and MUST advertise
exactly the capabilities it backs (`assertRequiredCapabilities` enforces the
required four at construction; a test pins it). Optional methods are present iff
the matching capability is advertised. Calling an unadvertised optional op throws
`CAPABILITY_UNSUPPORTED` — never a silent no-op.
### Typed failures
Mirrors `runtime/context.js`'s `ContextError` discipline (a closed code set,
constructor throws on an unknown code):
| Code | Meaning |
|------|---------|
| `PROVIDER_UNAVAILABLE` | CLI/MCP transport absent — degrade to file-only |
| `PROVIDER_NOT_CONSENTED` | repo indexing not consented and content would leave the machine |
| `CAPABILITY_UNSUPPORTED` | provider declines this op |
| `SOURCE_NOT_REGISTERED` | op needs a source that isn't registered |
| `PROVIDER_TIMEOUT` | provider exceeded the op timeout |
| `PROVIDER_ERROR` | provider ran and failed |
`PROVIDER_UNAVAILABLE` is the load-bearing one: callers catch it (or use the
picker's null resolution) and fall back to grep / file-only. It is never fatal.
### Consent
Two orthogonal consent axes, both explicit, neither auto-granted:
1. **Network / content-egress consent (repo-scoped).** Before any repo content
leaves the machine, indexing must be consented *per repo*. The contract
enforces this in `registerSource`/`refresh`/`add`: when
`provider.local === false` and `opts.consented !== true`, it throws
`PROVIDER_NOT_CONSENTED`. Local providers (Graphify) skip this axis — nothing
leaves the machine.
2. **Install consent (Graphify only).** Graphify is never auto-installed. The
`options`/`status` display marks it available only when its CLI is present,
and nothing in gstack runs a Graphify installer. Install is a user action
(`pip install graphifyy && graphify install`).
This matches the Context.dev model: selection persists without granting egress
consent; egress requires a separate explicit step.
## Per-provider capability matrix
| Op | GBrain (recommend first) | Sourcebot | Graphify |
|----|--------------------------|-----------|----------|
| `register_source` | ✓ `sources add --federated` | ✓ local `git` connection in config.json | ✓ `graphify update <dir>` (local, no LLM) |
| `refresh` | ✓ `sync` + `sync --strategy code --full` | ✓ auto (config change + reindexIntervalMs) | ✓ `graphify update <dir>` |
| `search` | ✓ `gbrain search` (federated corpora) | ✓ `POST /api/search` (keyless w/ anonymous access; Bearer key optional) | ✓ `graphify query "<q>" --graph <graph.json>` |
| `status` | ✓ `sources list` + page_count | ~ partial (server liveness) | ~ partial (graph.json present + node count) |
| `add` | ✓ `put <slug>` | ✗ declines | ✗ declines |
| `delete` | ✓ `delete <slug>` | ✗ declines | ✗ declines |
| `export` | ✓ `export` | ✗ declines | ✓ read `graphify-out/graph.json` |
| `local` (no egress) | no (federated DB) | loopback → **yes**; remote host → no | **yes** (local only) |
All three are driven directly from the runtime — no MCP client:
- **GBrain** (`garrytan/gbrain`, the gstack-ecosystem tool): full contract fit,
driven via the existing `gbrain` CLI chokepoint (`lib/gbrain-exec.ts`). Native
primitive is document-by-slug (put/delete/get/export) PLUS a repo axis
(`sources add`/`sync`). Advertises all seven capabilities. **Recommended
first.**
- **Sourcebot** (`github.com/sourcebot-dev/sourcebot`, YC Fall 2025): self-hosted
whole-repo regex search, deployed via Docker Compose (bundled server + Postgres
+ Redis; no supported non-Docker path). `register_source` adds a local `{ "type":
"git", "url": "file:///path" }` connection to the server's `config.json` (it
re-indexes on config change; a local repo needs a `remote.origin.url` or it is
skipped); `search` is `POST {baseUrl}/api/search`; `status` probes that endpoint.
Declines `add`/`delete`/`export`. It is a **local** tool — indexed code stays on
your machine — and an **API key is optional**: a local instance with anonymous
access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` keyless. The
adapter sends `Authorization: Bearer <SOURCEBOT_API_KEY>` only when a key is set.
A loopback `baseUrl` keeps content on the machine (local=true); a remote one
requires egress consent.
- **Graphify** (`github.com/Graphify-Labs/graphify`, YC-backed): local
tree-sitter code graph via the `graphify` CLI. The adapter uses **`graphify
update <dir>`** — the local, no-LLM build (writes `graphify-out/graph.json`);
it deliberately avoids the bare `graphify <dir>` build, which runs an LLM
extraction backend needing an API key + network. `graphify query "<q>" --graph
<graph.json>` searches it (its `NODE ...`/`EDGE ...` output carries the file at
`src=`/`at=`); `export` reads the graph JSON. Fully local — nothing leaves the
machine. Optional, **install only with explicit user action** (`pip install
graphifyy && graphify install`, needs Python >= 3.10); never auto-installed.
**No local-index option is offered** (deliberately excluded — a naive local
index degrades result quality; we route to a real provider or to file-only grep,
not to a half-baked in-house index).
### Integration surfaces (no MCP needed)
Each provider exposes a runtime-drivable surface, so gstack drives them with a
CLI shell-out or plain HTTP — it never speaks MCP:
- **GBrain / Graphify: CLI.** Shell out (`spawnSync`), same shape and the same
ENOENT→`PROVIDER_UNAVAILABLE` degrade as the existing gbrain glue.
- **Sourcebot: HTTP + a config-file edit.** `POST /api/search` for queries and a
JSON edit of the server's `config.json` to register a repo. `fetch` is
injectable so tests run against a stub, no live server.
Sourcebot and Graphify also ship MCP servers for in-agent use; the contract does
not depend on them, because their CLI/HTTP surfaces are enough to index and
search from the runtime.
## Picker: recommend GBrain first
`lib/code-intelligence/picker.ts` + `selection.ts`. The user picks a provider
with `gstack-code-intelligence select <provider>`, persisted to
`$GSTACK_HOME/code-intelligence.json`. `resolveSelectedProvider()` constructs the
selected provider, or returns `null` when nothing is selected — the provider-OFF
path, where callers degrade to grep / the file-only decision store. Availability
is proven at call time: a selected provider whose CLI/server is absent throws
`PROVIDER_UNAVAILABLE`, which callers catch and degrade on.
`RECOMMENDED_ORDER` is the static **GBrain → Sourcebot → Graphify** fact — GBrain
is always recommended first. `detectAvailable()` probes each provider for the
`options`/`status` display (GBrain via the real `localEngineStatus()`; Graphify
via its CLI/graph presence; Sourcebot via an HTTP liveness probe). The picker
never silently prefers a non-recommended tool.
## How this replaces the current GBrain glue
The contract is the seam; the bespoke glue collapses onto it. Mapping:
| Today (bespoke) | Under the contract |
|-----------------|--------------------|
| `bin/gstack-gbrain-sync.ts` (`sync`/`reindex-code`/`sources`) | `provider.registerSource` / `provider.refresh` |
| `lib/gstack-decision-semantic.ts` `semanticRecall` | `provider.search` (scoped) → same degrade-to-null |
| `bin/gstack-brain-context-load.ts` (`query`/`list_pages`) | `provider.search` / `provider.status` |
| `bin/gstack-memory-ingest.ts` (`import`, put) | `provider.add` (optional cap; GBrain-only) |
| `lib/gbrain-sources.ts` (`ensureSourceRegistered`, `probeSource`) | GBrain adapter internals |
| `lib/gbrain-local-status.ts` | GBrain adapter availability probe (kept, reused) |
| `bin/gstack-gbrain-detect` / `-install` / `-source-wireup` / `-repo-policy` | provider setup + picker + consent (thinner) |
The point is not to delete 17k LOC in one commit — it is to make every consumer
call the contract, then retire the bespoke paths provider-by-provider behind it.
Consumers that only need "search my code, or degrade" stop importing gbrain
specifics entirely.
## Rollout
Phased, each phase independently revertable. Skill-template edits are deferred to
a later phase precisely so the first slices do not trigger the
`gen:gstack2` / parity re-baseline cycle.
- **Phase 1 (this slice): the contract, three real adapters, and a usable CLI.**
`contract.ts` + fully-drivable GBrain (CLI), Graphify (CLI), and Sourcebot
(HTTP + config) adapters + the selection store + the `gstack-code-intelligence`
CLI (`options`/`status`/`select`/`consent`/`index`/`search`) + tests. A user
can select a provider and index/search their repo today. No skill-template or
generated-file changes yet, so no `gen:gstack2` / parity re-baseline.
- **Phase 2: route internal consumers through the contract.** Point
`gstack-decision-semantic` and `gstack-brain-context-load` at
`resolveSelectedProvider()`, preserving degrade-to-null exactly. Behavior-neutral
for the file-only paths.
- **Phase 3: surface selection in the skills.** Offer the picker at the moments a
skill would benefit from indexed search, mirroring the `context` command's
just-in-time consent prompt. Regenerate skills (`bun run gen:gstack2`), re-run
`bun run test:gstack2`, re-baseline parity intentionally.
- **Phase 4: retire bespoke glue.** Once every consumer is on the contract,
delete the sync/ingest/cache entrypoints and their tests provider-by-provider.
## Verified against real environments
All three adapters were driven against the real tools in isolated environments
(parallel agents, one worktree each), and all three now index + search a real repo
end-to-end. Two rounds ran, because the first round's fixes included a mistake that
only real execution caught — recorded here honestly.
- **GBrain — real Postgres+pgvector (Docker), gbrain 0.42.56 — PROVEN.** The
default pglite/WASM engine is broken on macOS (upstream garrytan/gbrain#223), so
the working recipe points gbrain at a real Postgres via `DATABASE_URL`. Real
end-to-end search returned the actual code definition
(`[0.88] src-checksum-ts … export statement computeChecksum`). Real execution
caught a **regression I had introduced**: I removed `--strategy code` from
`refresh` based on a `--help` misread, which silently stopped code from ever
being indexed (only docs were). Restored to the verified two-pass
(`sync`, then `sync --strategy code --full`); `--federated` registration is
load-bearing for global search. Also fixed earlier: engine-down now degrades to
`PROVIDER_UNAVAILABLE` (one-line message) instead of `PROVIDER_ERROR` + a WASM
stack dump.
- **Sourcebot — live v6.5.0 (Docker) — PROVEN keyless.** Endpoint, body, and
response parsing were correct against the real server. Correcting an earlier
wrong conclusion: Sourcebot does **not** require an API key for local use —
enabling anonymous access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves
`/api/search` keyless, verified with a real hit through the CLI with no key set.
The key stays optional; the only fix was messaging (point users to anonymous
access first, key as fallback) plus a note that a local repo needs a
`remote.origin.url` to be indexed. It is a local tool (code stays on the
machine; a boot telemetry ping unless `SOURCEBOT_TELEMETRY_DISABLED=true`).
- **Graphify — real install, graphify 0.9.23 — PROVEN.** Correcting an earlier
wrong claim of mine: for **code**, `graphify <dir>` and `graphify update <dir>`
produce the identical AST graph with **no LLM call**; the LLM only renames
community clusters and ingests non-code docs, adding zero nodes/edges, and our
parser discards the field it touches. So there is deliberately no LLM mode, and
`local=true` is correct. The adapter uses `graphify update`; real `index`+search
returned correct `file:line` refs. Also fixed: `search` now reads the indexed
repo's graph (persisted root), and `options` reports an installed provider as
available.
The larger lesson, kept on the record: a `--help` reading or a single agent's
conclusion is not proof — running the real tool is. It reversed two of my
first-round calls (the gbrain flag removal and the graphify LLM claim).
## What this does NOT change
Per the GStack 2 canonical contract and CLAUDE.md boundaries: no cloud browsers,
no alternate iOS drivers, no local image models, no provider marketplaces, no
workflow engines, **no new state database**. Context.dev remains the only
newly-authorized external service for web context; this contract governs code
intelligence, a separate axis. The existing decision store and Context Recovery
stay file-only and provider-independent.
## Testing
`test/code-intelligence.test.ts` (19 tests, no live tools): capability-matrix
invariants (all providers advertise the four required; only GBrain advertises the
document ops; `local` flags, including loopback-vs-remote Sourcebot); the result
parsers; the selection store + per-repo consent + provider-OFF (`null`); consent
gating (GBrain non-local without consent throws `PROVIDER_NOT_CONSENTED`; local
Graphify is exempt); the GBrain adapter against a fake `gbrain` shim; the Graphify
adapter against a fake `graphify` shim (index builds a graph, search returns hits,
status counts nodes); the Sourcebot adapter against an injected `fetch` + a temp
`config.json` (register writes a local git connection, search maps `files[]` to
hits); and every adapter degrading to `PROVIDER_UNAVAILABLE` when its tool/server
is absent. The `gstack-code-intelligence` CLI was smoke-tested end-to-end:
select → consent gate → local Graphify index (5-node graph) → search.

View File

@ -93,3 +93,17 @@ V2 items remain deferred:
- Per-skill or per-topic explain levels
- Team profiles
- AST-based "delivered features" metric
## Fold-in from fork port wave 2 (2026-08-14)
The time-attack/gstack fork attacked the same question fatigue from a
complementary axis: build-scale classification (session/hobby/project/
product/venture) sizing the machinery, plus CHAIN-WIDE question budgets.
Approved decision (CEO review 2026-08-14): fold the fork's ACCOUNTING
judgment into this design round — the budget is chain-scoped (a chained
review deducts from what's left, never resets), handoffs carry
questions-already-spent, approval/mutation gates never count against it, and
the budget is spent on the hardest-to-reverse decisions first. Do NOT adopt
the fork's 5/8/12 numeric constants — the fork itself later replaced them
with a zero-default autonomy dial. Scale sizes the machinery and sets the
budget; pacing (this doc) ranks what the budget is spent on.

View File

@ -241,7 +241,9 @@ Codex, Factory) is "host explosion for a path alias." The current architecture h
- Per-host branches in `transformFrontmatter()` with near-duplicate logic
- Per-host config in `EXTERNAL_HOST_CONFIG` with similar patterns
- Per-host functions in the setup script (`create_codex_runtime_root`, `link_codex_skill_dirs`)
- Host names duplicated in `bin/gstack-platform-detect`, `bin/gstack-uninstall`, `bin/dev-setup`
- Host names duplicated in `bin/gstack-platform-detect` (since deleted — host
detection now lives in the `hosts/` registry, exported to shell via
`scripts/host-config-export.ts`), `bin/gstack-uninstall`, `bin/dev-setup`
Adding Slate means copying all of these patterns again. A refactor to make hosts
data-driven (config objects instead of if/else branches) would make Slate integration
@ -253,7 +255,9 @@ trivial AND make future hosts (any new OpenCode fork, any new agent) zero-effort
have Slate skills
- `bin/gstack-uninstall` doesn't know about `.slate/`
- `bin/dev-setup` doesn't wire `.slate/` for contributor dev mode
- `bin/gstack-platform-detect` doesn't detect Slate
- `bin/gstack-platform-detect` doesn't detect Slate (obsolete: the bin was
deleted; host detection is now the `hosts/` registry via
`scripts/host-config-export.ts``hosts/slate.ts` is where Slate lives)
- E2E tests should set `SLATE_DISABLE_CLAUDE_CODE_SKILLS=1` to prove `.slate/` path
actually works (not just falling back to `.claude/`)

View File

@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -45,6 +45,13 @@ JOURNAL="${MIGRATION_DIR}/v1.27.0.0.journal"
DONE="${MIGRATION_DIR}/v1.27.0.0.done"
SKIPPED="${MIGRATION_DIR}/v1.27.0.0.skipped-by-user"
# Real, copy-pasteable re-run command for every remediation message below.
# There is no runner re-ask: the upgrade runners' version windows never
# re-select an already-passed migration, so the only honest remediation is
# a direct invocation of this script ($0-derived so it survives any cwd).
SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
RERUN_CMD="GSTACK_MIGRATE_ASSUME_YES=1 bash ${SELF_PATH}"
USER_NAME="${USER:-$(whoami 2>/dev/null || echo unknown)}"
OLD_REPO_NAME="gstack-brain-${USER_NAME}"
NEW_REPO_NAME="gstack-artifacts-${USER_NAME}"
@ -61,8 +68,8 @@ mkdir -p "$MIGRATION_DIR"
# Already done? exit silently.
[ -f "$DONE" ] && exit 0
# User opted out previously? exit silently. (Re-invoke via
# `/setup-gbrain --rerun-migration` removes this marker.)
# User opted out previously? exit silently. (To re-run after an opt-out:
# rm the skipped-by-user marker, then invoke this script directly.)
[ -f "$SKIPPED" ] && exit 0
journal_done() {
@ -119,19 +126,33 @@ EOF
read -r REPLY || REPLY=""
case "$REPLY" in
n|N|no|No|NO)
echo " Skipping migration. Re-run via /setup-gbrain --rerun-migration." >&2
echo " Skipping migration. To re-run later:" >&2
echo " rm ${SKIPPED} && ${RERUN_CMD}" >&2
touch "$SKIPPED"
exit 0
;;
skip|skip-for-now|s)
echo " Skipping for now. Will ask again next upgrade." >&2
# Don't write SKIPPED — leave both old + new state untouched, ask again next time.
echo " Skipping for now. Re-run manually with: ${RERUN_CMD}" >&2
# Don't write SKIPPED — leave both old + new state untouched. The
# upgrade runner will NOT re-select this migration, so re-running is
# manual via the command above.
exit 0
;;
esac
else
# Non-interactive (CI, scripted upgrade): proceed automatically.
echo " (non-interactive: proceeding automatically)" >&2
# Non-interactive (CI, Claude Code Bash tool, scripted upgrade). Step 1
# renames a REMOTE repo — consent-shaped, and blanket auto-proceed once
# left an install half-migrated when that step failed mid-run (#1383).
# Skip for now by default (asked again next upgrade); explicit opt-in
# proceeds unattended.
if [ "${GSTACK_MIGRATE_ASSUME_YES:-0}" = "1" ]; then
echo " (non-interactive: proceeding — GSTACK_MIGRATE_ASSUME_YES=1)" >&2
else
echo " Non-interactive session: skipping for now." >&2
echo " Re-run manually with: ${RERUN_CMD}" >&2
echo " To run interactively: bash ${SELF_PATH}" >&2
exit 0
fi
fi
fi
@ -195,8 +216,8 @@ if ! journal_done "gh_repo_renamed"; then
mark_done "gh_repo_renamed"
else
echo " WARNING: gh rename failed (repo may not exist or permission denied)" >&2
echo " skipping step 1; subsequent steps still run" >&2
mark_done "gh_repo_renamed"
echo " step 1 stays PENDING and will retry on re-run; later steps still run (#1383)" >&2
echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_REPO_NAME --yes" >&2
fi
fi
else
@ -335,8 +356,20 @@ EOF
fi
# ---------------------------------------------------------------------------
# Step 6: finalize (touchfile + clear journal)
# Step 6: finalize (touchfile + clear journal) — only when EVERY step is
# journaled. A failed step must leave the migration visibly incomplete and
# retryable, never silently recorded as done (#1383).
# ---------------------------------------------------------------------------
INCOMPLETE=""
for _step in gh_repo_renamed remote_txt_renamed config_key_renamed claude_md_block_rewritten sources_swapped; do
journal_done "$_step" || INCOMPLETE="$INCOMPLETE $_step"
done
if [ -n "$INCOMPLETE" ]; then
echo " [v1.27.0.0] migration INCOMPLETE — pending step(s):$INCOMPLETE" >&2
echo " Completed steps are journaled and will be skipped on re-run." >&2
echo " Re-run manually with: ${RERUN_CMD}" >&2
exit 1
fi
touch "$DONE"
rm -f "$JOURNAL"

View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Migration: v1.65.0.0 — repair Chrome-for-Testing bundles poisoned by the
# old in-place rebrand (#2242).
#
# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's
# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote
# its Resources/*.icns — inside the SHARED Playwright cache. That broke the
# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and
# poisoned the cache for the user's OTHER Playwright projects too. Deleting
# the rebrand code fixes fresh installs only; every existing macOS install
# still has the mutated bundle on disk. This migration removes poisoned
# bundles and re-fetches a clean one so the upgrade doesn't leave the user
# with zero working browser (the browse launch path also self-heals, as the
# belt to this suspenders, for installs that never run migrations).
#
# Removal scope: `playwright install chromium` treats the revision dir's
# INSTALLATION_COMPLETE marker as "is already downloaded" — removing only
# the .app strands the user with a marker, no browser, and a re-fetch that
# no-ops. So when the poisoned .app sits in the standard cache layout
# (chromium-<rev>/chrome-mac/<name>.app) the WHOLE revision dir goes;
# otherwise the .app plus its sibling INSTALLATION_COMPLETE /
# DEPENDENCIES_VALIDATED markers go. A revision dir already stranded in
# exactly that state (markers present, .app missing) is detected and
# removed too, so the re-fetch actually downloads.
#
# Affected: macOS installs that ever ran headed mode before v1.64.
#
# Idempotent: detection is content-based (plist contains "GStack Browser");
# a clean cache is a no-op, and the .done touchfile gates re-runs. After a
# removal, .done is only written once the end state is VERIFIED (a real
# Chromium executable exists in the cache) — a removal followed by a failed
# re-fetch (e.g. offline) leaves the migration pending, with a needs-refetch
# sentinel so the next run retries the download.
set -u
GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}"
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
DONE="${MIGRATION_DIR}/v1.65.0.0.done"
# Written when a removal happened but the verified end state (a Chromium
# executable in the cache) wasn't reached — e.g. the re-fetch failed
# offline. Its presence re-triggers the re-fetch on the next run even when
# the scans below find nothing left to remove.
NEEDS_REFETCH="${MIGRATION_DIR}/v1.65.0.0.needs-refetch"
mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true
[ -f "${DONE}" ] && exit 0
# macOS only: the mutation targeted .app bundle plists.
if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then
touch "${DONE}"
exit 0
fi
PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}"
REMOVED=0
[ -f "${NEEDS_REFETCH}" ] && REMOVED=1
is_revision_dir() {
# Standard Playwright cache revision dir name: chromium-<digits>.
printf '%s' "$(basename "$1")" | grep -Eq '^chromium-[0-9]+$'
}
if [ -d "${PW_CACHE}" ]; then
# 1. Content-based poison scan: every Chrome-for-Testing bundle in the
# cache (one per pinned chromium build) whose plist carries the rebrand.
while IFS= read -r plist; do
if grep -q "GStack Browser" "${plist}" 2>/dev/null; then
app_dir="$(dirname "$(dirname "${plist}")")"
case "${app_dir}" in
"${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app)
rev_dir="$(dirname "$(dirname "${app_dir}")")"
if is_revision_dir "${rev_dir}"; then
# Remove the WHOLE revision dir: Playwright's
# INSTALLATION_COMPLETE marker lives beside chrome-mac/, and
# `playwright install chromium` treats its presence as "already
# downloaded" — removing only the .app would make the re-fetch
# below a no-op and leave the user with NO browser.
echo " [v1.65.0.0] removing rebrand-poisoned revision dir (incl. install markers): ${rev_dir}" >&2
rm -rf "${rev_dir}"
else
echo " [v1.65.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2
rm -rf "${app_dir}"
rm -f "$(dirname "${app_dir}")/INSTALLATION_COMPLETE" \
"$(dirname "${app_dir}")/DEPENDENCIES_VALIDATED" 2>/dev/null || true
fi
REMOVED=1
;;
*)
echo " [v1.65.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2
;;
esac
fi
done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null)
# 2. Stranded-state scan: an earlier version of this migration removed
# only the poisoned .app, leaving the revision dir with its
# INSTALLATION_COMPLETE marker — the exact state that makes
# `playwright install chromium` no-op while the user has NO browser.
# A chromium revision dir without any .app inside is that strand;
# remove it whole so the re-fetch actually downloads.
for rev_dir in "${PW_CACHE}"/chromium-*; do
[ -d "${rev_dir}" ] || continue
is_revision_dir "${rev_dir}" || continue
if [ -z "$(find "${rev_dir}" -maxdepth 2 -name '*.app' -print 2>/dev/null | head -1)" ]; then
echo " [v1.65.0.0] removing stranded revision dir (install markers without a browser): ${rev_dir}" >&2
rm -rf "${rev_dir}"
REMOVED=1
fi
done
fi
if [ "${REMOVED}" = "1" ]; then
# Re-fetch immediately: migrations run AFTER ./setup, so without this the
# user finishes the upgrade with no working browser at all (headless AND
# headed use the same bundle). Run from the gstack install root so bunx
# resolves the repo-pinned playwright version — an arbitrary migration-
# runner cwd could resolve a different playwright and populate a revision
# the pinned one never launches (same subshell-cd pattern as ./setup's
# Chromium install block).
SCRIPT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
echo " [v1.65.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2
if command -v bunx >/dev/null 2>&1 && (cd "${SCRIPT_DIR}" && bunx playwright install chromium >&2); then
echo " [v1.65.0.0] playwright install finished." >&2
else
echo " [v1.65.0.0] WARNING: automatic re-fetch failed." >&2
fi
# Gate .done on the VERIFIED end state, not the installer's exit code:
# `playwright install` exits 0 even when it skips the download, and a
# successful removal followed by a failed/offline fetch must not be
# recorded as done — that would strand the user with no browser and a
# success message.
CHROME_EXE="$(find "${PW_CACHE}" -maxdepth 6 -type f -perm -u+x -path "*/chromium-*/*.app/Contents/MacOS/*" 2>/dev/null | head -1)"
if [ -n "${CHROME_EXE}" ]; then
echo " [v1.65.0.0] verified working Chromium at: ${CHROME_EXE}" >&2
rm -f "${NEEDS_REFETCH}" 2>/dev/null || true
else
touch "${NEEDS_REFETCH}" 2>/dev/null || true
echo " [v1.65.0.0] WARNING: no Chromium executable present after removing the poisoned bundle." >&2
echo " [v1.65.0.0] Headless AND headed browsing are unavailable until it is re-fetched. Run:" >&2
echo " [v1.65.0.0] cd ${SCRIPT_DIR} && bunx playwright install chromium" >&2
echo " [v1.65.0.0] Leaving this migration pending — it retries on the next run." >&2
exit 0
fi
else
echo " [v1.65.0.0] no rebrand-poisoned bundles found — no-op." >&2
fi
touch "${DONE}"
exit 0

View File

@ -676,6 +676,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -715,6 +715,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -680,6 +680,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -681,6 +681,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -684,6 +684,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -60,14 +60,20 @@ export async function writeAudit(row: AuditRow, path: string = defaultAuditPath(
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
}
// Non-reversible identifier for tokens/identities in logs and API responses.
// Same device salt as the attempts log, so ids correlate across both.
export async function saltedHash(raw: string): Promise<string> {
const salt = await loadDeviceSalt();
return createHash('sha256').update(salt + ':' + raw).digest('hex').slice(0, 16);
}
export async function writeAttempt(opts: {
rawIdentity: string;
endpoint: string;
reason: AttemptRow['reason'];
path?: string;
}): Promise<void> {
const salt = await loadDeviceSalt();
const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16);
const hash = await saltedHash(opts.rawIdentity);
const row: AttemptRow = {
ts: new Date().toISOString(),
identity_canon: hash,

View File

@ -19,7 +19,7 @@ import { probeTailscale, whoIs } from './tailscale-localapi';
import { SessionTokenStore } from './session-tokens';
import { mintForCaller } from './auth-mint';
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit';
import { bootstrapTunnel } from './tunnel-bootstrap';
import { startTunnelKeepalive } from './devicectl';
import type { Capability } from './types';
@ -362,20 +362,38 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
return;
}
// /auth/sessions — list active sessions (owner only).
// /auth/sessions — list active sessions (owner only). Raw token values
// never leave the store: any local process can hit this listener, so a
// list that echoed live bearer tokens was a harvest-and-replay primitive.
// Callers get a salted-hash id plus metadata; revoke by identity, by the
// token they already hold from mint, or by token_id from this list.
if (method === 'GET' && path === '/auth/sessions') {
sendJson(res, 200, { sessions: tokenStore.list() });
const sessions = await Promise.all(tokenStore.list().map(async ({ token, ...meta }) => ({
token_id: await saltedHash(token),
...meta,
})));
sendJson(res, 200, { sessions });
return;
}
// /auth/revoke — revoke a token.
// /auth/revoke — revoke by raw token (the caller's own, from mint), by
// token_id (from /auth/sessions — keeps the list→revoke workflow alive
// now that the list is hash-only), or by identity.
if (method === 'POST' && path === '/auth/revoke') {
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string };
const parsed = JSON.parse(body.toString('utf-8') || '{}') as {
token?: string; token_id?: string; identity?: string;
};
let count = 0;
if (parsed.token) {
count = tokenStore.revoke(parsed.token) ? 1 : 0;
} else if (parsed.token_id) {
for (const s of tokenStore.list()) {
if ((await saltedHash(s.token)) === parsed.token_id) {
count += tokenStore.revoke(s.token) ? 1 : 0;
}
}
} else if (parsed.identity) {
count = tokenStore.revokeByIdentity(parsed.identity);
}

View File

@ -107,6 +107,83 @@ describe('daemon — loopback listener', () => {
rmSync(workDir, { recursive: true, force: true });
});
test('/auth/sessions returns salted-hash ids and metadata, never raw tokens', async () => {
const minted = daemon.tokenStore.mint({
identity: 'owner@example.com',
capability: 'interact',
deviceUdid: 'STUB-UDID',
origin: 'owner_granted',
});
if ('error' in minted) throw new Error(minted.error);
try {
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
expect(r.status).toBe(200);
// The harvest-and-replay primitive: any local process could read live
// bearer tokens off this endpoint. The raw token must never appear.
expect(r.bodyText).not.toContain(minted.token);
const { sessions } = JSON.parse(r.bodyText) as { sessions: Array<Record<string, unknown>> };
const row = sessions.find(s => s.identity === 'owner@example.com');
expect(row).toMatchObject({
capability: 'interact',
device_udid: 'STUB-UDID',
origin: 'owner_granted',
expires_at: minted.expires_at,
});
expect(row?.token_id).toMatch(/^[0-9a-f]{16}$/);
expect(row?.token).toBeUndefined();
} finally {
daemon.tokenStore.revoke(minted.token);
}
});
test('revoke by token_id from the hash-only list still works (list→revoke)', async () => {
const minted = daemon.tokenStore.mint({
identity: 'revoke-by-id@example.com',
capability: 'observe',
origin: 'owner_granted',
});
if ('error' in minted) throw new Error(minted.error);
const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
const { sessions } = JSON.parse(list.bodyText) as { sessions: Array<Record<string, unknown>> };
const row = sessions.find(s => s.identity === 'revoke-by-id@example.com');
expect(row?.token_id).toBeDefined();
const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, {
body: JSON.stringify({ token_id: row!.token_id }),
});
expect(revoke.status).toBe(200);
expect(JSON.parse(revoke.bodyText).revoked).toBe(1);
expect(daemon.tokenStore.list().some(s => s.identity === 'revoke-by-id@example.com')).toBe(false);
});
test('revoke with an unknown token_id revokes nothing and leaves live sessions untouched', async () => {
const minted = daemon.tokenStore.mint({
identity: 'unknown-id-survivor@example.com',
capability: 'observe',
origin: 'owner_granted',
});
if ('error' in minted) throw new Error(minted.error);
try {
// Well-formed (16 hex chars) but matches no session's salted hash.
const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, {
body: JSON.stringify({ token_id: '0'.repeat(16) }),
});
expect(revoke.status).toBe(200);
expect(JSON.parse(revoke.bodyText).revoked).toBe(0);
// The minted session must survive: still in the token store...
expect(daemon.tokenStore.list().some(s => s.identity === 'unknown-id-survivor@example.com')).toBe(true);
// ...and still visible on the list endpoint.
const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
const { sessions } = JSON.parse(list.bodyText) as { sessions: Array<Record<string, unknown>> };
expect(sessions.some(s => s.identity === 'unknown-id-survivor@example.com')).toBe(true);
} finally {
daemon.tokenStore.revoke(minted.token);
}
});
test('healthz returns 200 with mode=loopback', async () => {
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
expect(r.status).toBe(200);

View File

@ -1,14 +0,0 @@
{
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax.git",
"state" : {
"revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82",
"version" : "510.0.3"
}
}
],
"version" : 2
}

View File

@ -97,10 +97,12 @@ public final class StateServer {
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
@ -149,7 +151,19 @@ public final class StateServer {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {

View File

@ -678,6 +678,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -673,6 +673,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.
@ -803,6 +807,20 @@ the failure occurred (if outcome is error, otherwise use empty string "").
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
## Third-Party Web Actions
A step sometimes requires action on an external website the user controls: registering an API key, creating a vendor or developer account, configuring a dashboard, webhook, OAuth app, billing plan, or domain verification. This contract governs that moment. It grants no new browsing authority — the AskUserQuestion format and one-way-door rules remain binding, including approval before anything that spends money.
1. **Never hand the user a manual step list for a third-party site without first offering to drive it.** The driver is gstack's own browser stack: `$B` headed mode with handoff/resume for the human-only moments (see the /browse skill), or GStack Browser when installed. Never install new tooling to close the gap, and never treat tooling presence as consent to browse.
2. **One explicit question before any browsing.** STOP and name the exact site and the exact actions (for example "create a test-mode API token in the Duffel dashboard"), then offer: A) I drive it now in a visible browser — you take over for sign-in and approvals, B) manual instructions, C) defer. The selection is per-task consent; never persist it as standing permission and never infer it from an earlier task.
3. **When driving, touch only the named site and actions.** Password entry, new-account credential choice, payment, CAPTCHA, and identity verification are user-performed: hand off (`$B handoff`) and wait instead of acting. Prefer credential flows that never expose the secret to the agent, such as password-manager autofill or the dashboard's own copy button used by the human.
4. **A captured secret never appears in chat output, logs, or shell history.** Write it to a user-approved local file with owner-only permissions (0600) or the user's secret store, and keep generated destinations out of version control. Dashboard fields are often masked placeholders — verify the captured credential with ONE non-mutating API call before claiming success; a 401 here has caught a placeholder masquerading as a key.
5. **If the user declines or defers, or no browser is usable,** provide the manual steps and mark the step blocked on the user. Do not recommend or install new products to close the gap.
## SETUP (run this check BEFORE any browse command)
```bash

View File

@ -22,6 +22,8 @@ triggers:
{{PREAMBLE}}
{{THIRD_PARTY_ACTIONS}}
{{BROWSE_SETUP}}
{{BASE_BRANCH_DETECT}}

View File

@ -675,6 +675,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -676,6 +676,10 @@ When options differ in coverage, include `Completeness: X/10` (10 = all edge cas
For high-stakes ambiguity (architecture, data model, destructive scope, missing context), STOP. Name it in one sentence, present 2-3 options with tradeoffs, and ask. Do not use for routine coding or obvious changes.
## Claimed Limitations Need Evidence
A claimed limitation or requirement ("the API can't do this", "X requires a credential", "that's impossible on this platform") is a material claim. State one only with the verbatim error, the documented statement, or a live probe in hand — pattern-matching a failure to a familiar story is not evidence. When a cheap probe settles the question, run it BEFORE asking the user anything or declaring a step blocked.
## Continuous Checkpoint Mode
If `CHECKPOINT_MODE` is `"continuous"`: auto-commit completed logical units with `WIP:` prefix.

View File

@ -0,0 +1,201 @@
/**
* code-intelligence/contract the OPTIONAL, repo-oriented provider contract.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* gstack does not maintain a home-grown indexer. It defines this small contract
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
* contract is OPTIONAL: when no provider is available/consented,
* `resolveCodeProvider()` returns null and callers degrade to grep / the
* file-only decision store. Never a dependency, always an enhancement the same
* reliability contract as lib/gstack-decision-semantic.ts.
*
* Repo-oriented, not document-store (settled): register_source / refresh /
* search / status are required; add / delete / export are optional capabilities
* a provider MAY advertise. A document-CRUD-required contract would misrepresent
* whole-repo code-search and code-graph tools. See
* docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
*/
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
/**
* Policy op classification for the per-remote trust-tier veto (selection.ts).
* Write-class ops (register_source / index / refresh / add / delete) cause
* pages to be written, so BOTH `deny` and `read-only` tiers veto them the
* same semantics as runCodeImport in bin/gstack-gbrain-sync.ts ("code ingest
* writes pages"). Read-class ops (search / export / status) write nothing, so
* only `deny` vetoes them. Callers that don't say get "write" fail-closed.
*/
export type OpClass = "read" | "write";
export type CodeProviderCapability =
| "register_source"
| "refresh"
| "search"
| "status"
| "add"
| "delete"
| "export";
export const REQUIRED_CAPABILITIES: readonly CodeProviderCapability[] = [
"register_source",
"refresh",
"search",
"status",
] as const;
export const OPTIONAL_CAPABILITIES: readonly CodeProviderCapability[] = [
"add",
"delete",
"export",
] as const;
export interface RepoRef {
/** Source id the provider registers this repo under. */
id: string;
/** Local worktree path. */
path: string;
/** Remote URL, when the provider clones/manages it. */
remoteUrl?: string;
}
export interface SourceRef {
id: string;
}
export interface SourceStatus {
id: string;
state: "registered" | "indexing" | "ready" | "absent" | "unknown";
/** Pages / files / graph nodes, when the provider reports a count. */
itemCount?: number;
detail?: string;
/** True when the provider only implements a partial status probe. */
partial?: boolean;
}
export interface CodeSearchHit {
/** Slug, file path, or symbol id — whatever the provider keys results on. */
ref: string;
score?: number;
snippet?: string;
kind?: "document" | "file" | "symbol" | "graph-node";
}
export interface OpOptions {
/**
* Env override for spawned processes and egress-receipt home resolution.
* Production callers leave this unset; tests inject a synthetic env (fake
* CLI on PATH, temp GSTACK_HOME). Matches the existing gbrain helpers.
*/
env?: NodeJS.ProcessEnv;
/** Timeout in ms for the underlying op. */
timeout?: number;
/**
* Explicit per-repo consent that repo content may leave the machine. Required
* for non-local providers on register_source / refresh / add / search (the
* search query text is repo-derived content). The recorded value also feeds
* the egress receipt, which attests the ACTUAL consent state never assumed.
*/
consented?: boolean;
}
export interface SearchOptions extends OpOptions {
/** Restrict to a registered source. */
source?: string;
limit?: number;
minScore?: number;
}
export interface CodeProvider {
readonly id: CodeProviderId;
readonly label: string;
readonly capabilities: ReadonlySet<CodeProviderCapability>;
/** True when no repo content leaves the machine (Graphify). */
readonly local: boolean;
has(capability: CodeProviderCapability): boolean;
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
}
export const CODE_PROVIDER_FAILURES = Object.freeze([
"PROVIDER_UNAVAILABLE",
"PROVIDER_NOT_CONSENTED",
"CAPABILITY_UNSUPPORTED",
"SOURCE_NOT_REGISTERED",
"PROVIDER_TIMEOUT",
"PROVIDER_ERROR",
] as const);
export type CodeProviderFailure = (typeof CODE_PROVIDER_FAILURES)[number];
const FAILURE_SET = new Set<string>(CODE_PROVIDER_FAILURES);
/**
* Typed provider failure. Mirrors runtime/context.js ContextError discipline:
* the code set is closed and the constructor throws on an unknown code, so a
* typo can never mint an untyped failure.
*/
export class CodeProviderError extends Error {
readonly code: CodeProviderFailure;
readonly providerId?: CodeProviderId;
constructor(code: CodeProviderFailure, message: string, providerId?: CodeProviderId) {
if (!FAILURE_SET.has(code)) throw new TypeError(`Unknown code-provider failure code: ${code}`);
super(message);
this.name = "CodeProviderError";
this.code = code;
this.providerId = providerId;
}
}
/**
* Enforce that a provider advertises every required capability. Called by each
* adapter constructor so an incomplete provider fails fast, not at first search.
*/
export function assertRequiredCapabilities(
id: CodeProviderId,
capabilities: ReadonlySet<CodeProviderCapability>,
): void {
const missing = REQUIRED_CAPABILITIES.filter((cap) => !capabilities.has(cap));
if (missing.length) {
throw new TypeError(`Code provider ${id} is missing required capabilities: ${missing.join(", ")}`);
}
}
/**
* Guard for optional ops: throw CAPABILITY_UNSUPPORTED (never a silent no-op)
* when a provider is asked for a capability it does not advertise.
*/
export function assertCapability(provider: CodeProvider, capability: CodeProviderCapability): void {
if (!provider.has(capability)) {
throw new CodeProviderError(
"CAPABILITY_UNSUPPORTED",
`${provider.label} does not support "${capability}"`,
provider.id,
);
}
}
/**
* Repo-scoped egress consent gate. Non-local providers must not move repo
* content off the machine without explicit per-repo consent. Local providers
* (nothing leaves the machine) are exempt.
*/
export function assertEgressConsent(provider: CodeProvider, opts?: OpOptions): void {
if (provider.local) return;
if (opts?.consented === true) return;
throw new CodeProviderError(
"PROVIDER_NOT_CONSENTED",
`${provider.label} would send repo content off this machine; per-repo indexing consent is required`,
provider.id,
);
}

View File

@ -0,0 +1,283 @@
/**
* GBrain adapter full contract fit over the existing gbrain CLI chokepoint.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
* Windows-shim guarantees carry over unchanged. GBrain's native primitive is
* document-by-slug (put/delete/get/export) PLUS a repo axis (sources add/sync),
* so it advertises all seven capabilities.
*/
import { spawnSync } from "child_process";
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
import { spawnGbrain, buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "../gbrain-exec";
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources";
import {
assertCapability,
assertEgressConsent,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = [
"register_source",
"refresh",
"search",
"status",
"add",
"delete",
"export",
];
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* refresh() default. Full code indexing on the 1000+-tracked-file repos this
* feature targets routinely outruns the 30s op default; GraphifyProvider uses
* the same 120s ceiling for the same indexing work. Query/status stay at 30s.
*/
const REFRESH_TIMEOUT_MS = 120_000;
/**
* Environmental (engine / DB / config) failure shapes, shared by #assertOk and
* #wrap so the two paths can never drift. These degrade to
* PROVIDER_UNAVAILABLE (caller falls back to grep / file-only), not a hard
* PROVIDER_ERROR with a raw dump. Covers the real case where gbrain's pglite
* engine fails to init its WASM runtime (garrytan/gbrain#223) as well as
* unreachable/unconfigured databases and a missing CLI.
*/
const ENVIRONMENTAL_ERROR_RE =
/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i;
/**
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
* gbrain's search prints text, not JSON (verified in
* lib/gstack-decision-semantic.ts). Exported for deterministic unit testing.
*/
export function parseGbrainSearch(stdout: string, minScore: number, limit: number): CodeSearchHit[] {
const hits: CodeSearchHit[] = [];
for (const line of stdout.split("\n")) {
const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/);
if (!m) continue;
const score = parseFloat(m[1]);
if (!Number.isFinite(score) || score < minScore) continue;
hits.push({ ref: m[2], score, snippet: m[3].trim(), kind: "document" });
}
return hits.slice(0, limit);
}
export class GbrainProvider implements CodeProvider {
readonly id = "gbrain" as const;
readonly label = "GBrain";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
/** GBrain federates into a (possibly remote) DB, so content can leave the machine. */
readonly local = false;
constructor() {
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
/**
* Fail-closed egress receipt, written BEFORE every content-bearing send
* (register/refresh/add AND search/export). The gbrain subprocess owns the
* wire bytes, so the receipt records destination + payload class; sha256 is
* known when the exact payload text is (the `add` document body, the
* `search` query). The consent field records the ACTUAL consent state from
* opts the tamper-evident ledger must never attest consented=true for a
* send where nothing checked consent (every current caller asserts consent
* first, so the unchecked branch is defense-in-depth, not a live path).
*/
#receipt(payloadClass: string, opts: OpOptions, body?: string): void {
writeReceipt({
env: opts.env,
sink: "gbrain",
host: "gbrain-db (user-configured DATABASE_URL)",
payloadClass,
bytes: body == null ? 0 : Buffer.byteLength(body),
sha256: body == null ? null : sha256Hex(body),
consent: opts.consented === true
? "code-intelligence provider=gbrain + per-repo consented=true"
: "code-intelligence provider=gbrain + consent=unchecked (content-bearing ops assert consent before sending)",
});
}
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts);
this.#receipt("repo-source-registration (sent by gbrain subprocess)", opts);
try {
const result = await ensureSourceRegistered(repo.id, repo.path, {
federated: true,
env: opts.env,
});
return {
id: repo.id,
state: result.state.status === "match" ? "registered" : "unknown",
detail: result.changed ? "registered" : "already registered",
};
} catch (err) {
throw this.#wrap(err);
}
}
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts);
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
const timeout = opts.timeout ?? REFRESH_TIMEOUT_MS;
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
// 1. default sync (markdown strategy) — indexes docs.
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
// is never indexed (the whole point of a code provider); `code-def` stays
// "not_built" and search only finds incidental doc mentions. `--full`
// forces it past the per-source checkpoint the markdown pass advanced.
this.#assertOk(spawnGbrain(["sync", "--source", source.id], { baseEnv: opts.env, timeout }));
this.#assertOk(spawnGbrain(["sync", "--source", source.id, "--strategy", "code", "--full"], { baseEnv: opts.env, timeout }));
return this.status(source, opts);
}
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
// The query text is repo-derived content and DATABASE_URL may point at a
// remote DB. Unlike Sourcebot there is no cheap loopback check here — the
// URL is resolved inside the gbrain CLI's own config, not by this adapter
// — so EVERY send is treated as consent-requiring (fail closed, matching
// the contract's OpOptions doc). The throw happens before any bytes (or
// any receipt) exist; the receipt lands before the subprocess spawns.
assertEgressConsent(this, opts);
this.#receipt("code-search-query (sent by gbrain subprocess)", opts, query);
// `gbrain search` is global and has no `--source` flag; `--limit` is real
// (verified against gbrain 0.42.x --help).
const args = ["search", query];
if (opts.limit) args.push("--limit", String(opts.limit));
const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS });
this.#assertOk(r);
return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10);
}
async status(source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
if (!source) {
// No source given: liveness probe. `sources list` reachable = ready.
this.#assertOk(spawnGbrain(["sources", "list", "--json"], {
baseEnv: opts.env,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
}));
return { id: "*", state: "ready" };
}
try {
const probed = probeSource(source.id, opts.env);
if (probed.status === "absent") return { id: source.id, state: "absent" };
const count = sourcePageCount(source.id, opts.env);
return {
id: source.id,
state: "ready",
itemCount: count ?? undefined,
detail: probed.registered_path,
};
} catch (err) {
throw this.#wrap(err);
}
}
// Document ops (add/delete/export) are GBrain-only and secondary; they match
// gbrain's documented CLI surface (`put <slug>` reads stdin; `delete <slug>`;
// `export`) but could not be exercised against a live engine on the test host
// (pglite WASM broken, garrytan/gbrain#223), so treat them as best-effort.
async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise<SourceStatus> {
assertCapability(this, "add");
assertEgressConsent(this, opts);
this.#receipt("document-body (sent by gbrain subprocess)", opts, doc.body);
// `gbrain put <slug>` reads the document body from stdin.
this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts));
return { id: doc.slug, state: "ready" };
}
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
assertCapability(this, "delete");
// stdin closed ("") so any confirmation prompt gets EOF rather than hanging.
this.#assertOk(this.#runInput(["delete", slug], "", opts));
return { id: slug, state: "absent" };
}
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
assertCapability(this, "export");
// The export request federates into the same possibly-remote DB as
// search — same fail-closed consent gate + receipt (no loopback
// exemption exists for gbrain; see search()).
assertEgressConsent(this, opts);
this.#receipt("brain-export-request (sent by gbrain subprocess)", opts);
// `gbrain export` is brain-wide (no per-source flag); returns whatever it prints.
const r = spawnGbrain(["export"], {
baseEnv: opts.env,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
});
this.#assertOk(r);
return r.stdout || "";
}
/** spawn gbrain with `input` on stdin, seeded env, Windows-shim aware. */
#runInput(args: string[], input: string, opts: OpOptions) {
return spawnSync("gbrain", args, {
input,
encoding: "utf-8",
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
env: buildGbrainEnv({ baseEnv: opts.env }),
shell: NEEDS_SHELL_ON_WINDOWS,
});
}
/**
* Throw a typed failure unless the spawn succeeded. Distinguishes a missing
* CLI (ENOENT PROVIDER_UNAVAILABLE, the degrade signal) from a timeout
* (ETIMEDOUT/SIGTERM, status=null) and a real non-zero exit.
*/
#assertOk(r: {
status: number | null;
stderr?: string;
error?: Error & { code?: string };
signal?: NodeJS.Signals | null;
}): void {
if (r.status === 0) return;
const stderr = (r.stderr || "").trim();
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "gbrain CLI not on PATH", this.id);
}
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
}
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
// (caller falls back to file-only). Shapes hoisted to ENVIRONMENTAL_ERROR_RE.
if (ENVIRONMENTAL_ERROR_RE.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
}
#wrap(err: unknown): CodeProviderError {
if (err instanceof CodeProviderError) return err;
const message = err instanceof Error ? err.message : String(err);
// Same environmental-vs-real split as #assertOk — literally the same
// regex (ENVIRONMENTAL_ERROR_RE), so the two paths can never drift.
if (ENVIRONMENTAL_ERROR_RE.test(message)) {
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
}
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);
}
}
/** First non-empty line, so a multi-line WASM/stack dump never reaches the user. */
function firstLine(text: string): string {
return (text || "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
}

View File

@ -0,0 +1,201 @@
/**
* Graphify adapter real CLI integration (github.com/Graphify-Labs/graphify).
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
* and `graphify update <dir>` produce the SAME AST graph with NO LLM and NO
* network (verified against graphify 0.9.23 both emit `AST extraction on N
* code files`, all node origins `ast`). The LLM backend (openai/gemini) is only
* used to RENAME community clusters (`graphify label` / `cluster-only`) and to
* ingest non-code docs (`graphify add`); it adds zero nodes/edges, and our parser
* discards the `community=` field it touches so an LLM mode would send code
* off-machine for no change in search output, and is intentionally not offered.
*
* This adapter uses `graphify update <dir>` (writes `<dir>/graphify-out/graph.json`
* and does clustering in one shot) and stays fully local nothing leaves the
* machine, so `local = true` and no egress consent is needed.
*
* Query is `graphify query "<q>" --graph <dir>/graphify-out/graph.json`; the
* `--graph` flag points at the built graph so search never depends on cwd.
*
* Never auto-installed: install is `pip install graphifyy && graphify install`
* (needs Python >= 3.10), a user action the picker surfaces. When the CLI is
* absent every op throws PROVIDER_UNAVAILABLE and callers degrade to file-only.
*
* Path-based, not id-based: for Graphify a source "id" IS the absolute repo path
* (that is where `graphify-out/` lives), unlike GBrain's short source ids.
*/
import { spawnSync } from "child_process";
import { existsSync, readFileSync, statSync } from "fs";
import { join } from "path";
import {
assertCapability,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"];
const OUT_DIR = "graphify-out";
const GRAPH_JSON = "graph.json";
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
/**
* status() only parses graph.json for a node count when the file is at most
* this big. On the 1000+-file repos this feature targets, graph.json can run
* to hundreds of MB JSON.parsing that for a cosmetic count is a heap spike.
* Above the threshold the display reports the file size instead.
*/
const STATUS_PARSE_MAX_BYTES = 5 * 1024 * 1024;
export interface GraphifyOptions {
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
root?: string;
env?: NodeJS.ProcessEnv;
}
export class GraphifyProvider implements CodeProvider {
readonly id = "graphify" as const;
readonly label = "Graphify";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
/** Fully local — no repo content leaves the machine. */
readonly local = true;
readonly #root: string;
readonly #env?: NodeJS.ProcessEnv;
constructor(opts: GraphifyOptions = {}) {
this.#root = opts.root ?? process.cwd();
this.#env = opts.env;
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
#run(args: string[], cwd: string, timeout: number) {
return spawnSync("graphify", args, {
cwd,
encoding: "utf-8",
timeout,
stdio: ["ignore", "pipe", "pipe"],
env: this.#env,
shell: NEEDS_SHELL_ON_WINDOWS,
});
}
#assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void {
if (r.status === 0) return;
const stderr = (r.stderr || "").trim();
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id);
}
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id);
}
/** Build the graph over repo.path locally (no LLM, no egress consent needed). */
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run(["update", repo.path], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status({ id: repo.path }, opts);
}
/** Re-parse and rebuild the graph (same local `graphify update` path). */
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status(source, opts);
}
/**
* `graphify query "<q>" --graph <graph.json>` traces the graph and prints
* `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph`
* explicitly so the query reads the indexed repo's graph regardless of cwd.
*/
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
const root = opts.source ?? this.#root;
const graphPath = join(root, OUT_DIR, GRAPH_JSON);
const r = this.#run(["query", query, "--graph", graphPath], root, opts.timeout ?? DEFAULT_TIMEOUT_MS);
this.#assertOk(r);
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
}
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
const dir = source?.id ?? this.#root;
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
let itemCount: number | undefined;
let detail = graphPath;
try {
// stat first: parse the whole graph only when it's small (the node count
// is display-only, never worth a hundreds-of-MB JSON.parse heap spike).
const size = statSync(graphPath).size;
if (size <= STATUS_PARSE_MAX_BYTES) {
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
} else {
detail = `${graphPath} (${(size / (1024 * 1024)).toFixed(1)} MB graph; node count skipped)`;
}
} catch {
// graph.json present but unstatable/unparseable — still ready, just no count.
}
return { id: dir, state: "ready", itemCount, detail };
}
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
assertCapability(this, "export");
const graphPath = join(source.id, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) {
throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id);
}
return readFileSync(graphPath, "utf-8");
}
}
/**
* Parse real `graphify query` output into hits. The format (graphify 0.9.23):
* Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found
* NODE query() [src=db.py loc=L4 community=login]
* EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8
* The file lives mid-line (`src=<file> loc=L<n>` on NODE, `at=<file>:L<n>` on
* EDGE), so the ref is `<file>:L<n>`. The `Traversal:` header and any other line
* are skipped. Exported for deterministic unit testing against the real format.
*/
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
const hits: CodeSearchHit[] = [];
for (const raw of stdout.split("\n")) {
const line = raw.trim();
let ref: string | undefined;
const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/);
const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/);
if (node) ref = `${node[1]}:${node[2]}`;
else if (edge) ref = `${edge[1]}:${edge[2]}`;
else continue; // skip the Traversal header and anything non-NODE/EDGE
hits.push({ ref, snippet: line, kind: "graph-node" });
if (hits.length >= limit) break;
}
return hits;
}
/** Whether the `graphify` CLI is installed (for the picker's availability probe). */
export function graphifyInstalled(env?: NodeJS.ProcessEnv): boolean {
const r = spawnSync("graphify", ["--version"], {
encoding: "utf-8",
timeout: 5_000,
stdio: ["ignore", "ignore", "ignore"],
env,
shell: NEEDS_SHELL_ON_WINDOWS,
});
return r.status === 0;
}

Some files were not shown because too many files have changed in this diff Show More