mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
v1.12.0.0 feat: /setup-gbrain — coding-agent onboarding for gbrain (#1183)
* feat(setup-gbrain): add gstack-gbrain-repo-policy bin helper Per-remote trust-tier store for the forthcoming /setup-gbrain skill. Tiers are the D3 triad (read-write / read-only / deny), keyed by a normalized remote URL so ssh-shorthand and https variants collapse to the same entry. The file carries _schema_version: 2 (D2-eng); legacy `allow` values from pre-D3 experiments auto-migrate to `read-write` on first read, idempotent, with a one-shot log line. Pure bash + jq to match the existing gstack-brain-* family. Atomic writes via tmpfile + rename. Policy file mode 0600. Corrupt files quarantine to .corrupt-<ts> and start fresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(setup-gbrain): unit tests for gstack-gbrain-repo-policy 24 tests covering normalize (ssh/https/shorthand/uppercase collapse to one key), set/get round-trip, all three D3 tiers accepted, invalid tiers rejected, file mode 0600, _schema_version field written on fresh files, legacy allow migration (including idempotence and preservation of non-allow entries), corrupt-JSON quarantine + fresh-file recovery, list output sorting, and get-without-arg auto-detect against a git repo with no origin. All tests green against a per-test tmpdir GSTACK_HOME so nothing leaks into the real ~/.gstack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add gstack-gbrain-detect state reporter Pure-introspection JSON emitter for the /setup-gbrain skill's start-up branching. Reports: gbrain presence + version on PATH, ~/.gbrain/config.json existence + engine, `gbrain doctor --json` health (wrapped in timeout 5s to match the /health D6 pattern), gstack-brain-sync mode via gstack-config, and ~/.gstack/.git presence for the memory-sync feature. Never modifies state. Always emits valid JSON even when every check is false. Handles malformed ~/.gbrain/config.json without crashing — gbrain_engine is null in that case, not an error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add gstack-gbrain-install with D5 detect-first + D19 PATH-shadow guard Clones gbrain at a pinned commit (v0.18.2) and registers it via `bun link`. Before any clone: D5 detect-first — probes ~/git/gbrain, ~/gbrain, and the install target for a valid pre-existing clone (package.json with name "gbrain" and bin.gbrain set). If one is found, `bun link` runs there instead of cloning a second copy. Prevents the day-one duplicate-install footgun on the skill author's own machine. After install: D19 PATH-shadow guard — reads the install-dir's package.json version, compares to `gbrain --version` on PATH. On mismatch: exits 3, prints every gbrain binary on PATH via `type -a`, and gives a remediation menu. Setup skills refuse broken environments instead of warning and continuing. Prereq checks (bun, git, https://github.com reachability) fail fast with install hints. --dry-run and --validate-only flags let the skill probe the plan without touching state; tests use them to cover D5 and D19 without exercising real bun link. Pin is a load-bearing version: setup-gbrain v1 verified against gbrain v0.18.2. Updating requires re-running Pre-Impl Gate 1 to verify gbrain's CLI + config shapes haven't drifted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(setup-gbrain): unit tests for gstack-gbrain-detect + install 15 tests covering: detect emits valid JSON when nothing configured, reports gstack_brain_git on GSTACK_HOME/.git presence, reads ~/.gbrain/config.json engine, tolerates malformed config, detects a mocked gbrain binary on PATH with version parsing. For install: D5 detect-first uses ~/git/gbrain fixtures under a sandboxed HOME, verifies fall-through to fresh clone when no valid clone exists, rejects invalid package.json shapes. D19 PATH-shadow validation uses a fake gbrain on a minimal SAFE_PATH to simulate version mismatch, same-version-pass, v-prefix tolerance, missing binary on PATH, and missing version field in package.json. --validate-only mode in the install bin makes the D19 check unit- testable without running real bun link (which touches ~/.bun/bin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add gstack-gbrain-lib.sh with read_secret_to_env (D3-eng) Shared secret-read helper for PAT (D11) and pooler URL paste (D16). One implementation of the hardest-to-get-right pattern: stty -echo + SIGINT/TERM/EXIT trap that restores terminal mode, read into a named env var, optional redacted preview. Validates the target var name against [A-Z_][A-Z0-9_]* to prevent bash name-injection via `read -r "$varname"`. When stdin is not a TTY (CI, piped tests) the stty branches skip cleanly — piped input doesn't echo anyway. Exports the var after read so subprocesses inherit it; callers own the `unset` at handoff time. Sourced, not executed — no +x bit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add gstack-gbrain-supabase-verify structural URL check Zero-network validator for Supabase Session Pooler URLs before handing them to `gbrain init`. Canonical shape verified per gbrain init.ts:266: postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres Rejects direct-connection URLs (db.*.supabase.co:5432) with a distinct exit code 3 and clear IPv6-failure remediation — that's the most common paste mistake users make, so it earns its own UX path rather than a generic "bad URL" error. Never echoes the URL (contains a password) in error messages; tests verify a distinct seed password never appears in stderr on any reject path. Accepts URL from argv[1] or stdin ("-" or no arg). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(setup-gbrain): unit tests for supabase-verify + lib.sh secret helper 22 tests. verify: accepts canonical pooler URL (argv + stdin modes), rejects direct-connection URL with exit 3, rejects wrong scheme, wrong port, empty password, missing userinfo, plain 'postgres' user (catches direct-URL paste errors), wrong host, empty URL. Case-insensitive host match. Explicit negative: error messages never echo the URL password. lib.sh read_secret_to_env: reads piped stdin into the named env var, exports to subprocesses, redacted-preview emits masked form on stderr with the seed password absent, rejects invalid var names (lowercase, leading digit, hyphens), rejects missing/unknown flags, secret value never appears on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add gstack-gbrain-supabase-provision Management API wrapper Four subcommands: list-orgs, create, wait, pooler-url. Built against the verified Supabase Management API shape (Pre-Impl Gate 1): - POST /v1/projects with {name, db_pass, organization_slug, region} — not the original plan's /v1/organizations/{ref}/projects - No `plan` field; subscription tier is org-level per the OpenAPI description ("Subscription Plan is now set on organization level and is ignored in this request") - GET /v1/projects/{ref}/config/database/pooler for pooler config — not /config/database Secrets discipline: SUPABASE_ACCESS_TOKEN (PAT) and DB_PASS read from env only, never from argv (D8 grep test enforces this). `set +x` at the top as a defensive default so debug tracing never leaks secrets. Management API hostname hardcoded to SUPABASE_API_BASE env override — no user-controlled URL portion (SSRF guard). HTTP error paths: 401/403 → exit 3 (auth), 402 → 4 (quota), 409 → 5 (conflict), 429 + 5xx → exponential-backoff retry up to 3 attempts, then exit 8. Wait subcommand polls every 5s until ACTIVE_HEALTHY with a configurable timeout; terminal states (INIT_FAILED, REMOVED, etc.) exit 7 immediately with a clear message. Timeout emits the --resume-provision hint so the skill can recover. Pooler-url constructs the URL locally from db_user/host/port/name + DB_PASS rather than trusting the API response's connection_string field, which is templated with [PASSWORD] rather than the real value. Handles both object and array response shapes, preferring session pool_mode when Supabase returns multiple pooler configs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(setup-gbrain): unit tests for gstack-gbrain-supabase-provision via mock API 22 tests covering D21 HTTP error suite (401/403/402/409/429/5xx) and happy paths for all four subcommands. Every test spins up a Bun.serve mock server bound to SUPABASE_API_BASE so nothing hits the real API. Uses Bun.spawn (async) rather than spawnSync because spawnSync blocks the Bun event loop, which prevents Bun.serve mocks from responding — calls would hit curl's own timeout instead of round-tripping. Verifies: POST body contains organization_slug (not organization_id) and no `plan` field, bearer-token auth header, retry-on-429 with eventual success, exit-8 on persistent 5xx after max retries, wait succeeds on ACTIVE_HEALTHY, exits 7 on INIT_FAILED, exits 6 with --resume-provision hint on timeout, pooler-url builds URL locally from db_user/host/port/name + DB_PASS (not response connection_string template), handles array pooler responses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add SKILL.md.tmpl — user-facing skill prompt Stitches together every slice built so far (repo-policy, detect, install, lib.sh secret helper, supabase-verify, supabase-provision) into a single interactive flow. Paths: Supabase existing-URL, Supabase auto-provision (D7), Supabase manual, PGLite local, switch (PGLite ↔ Supabase via gbrain migrate wrapped in timeout 180s per D9). Secrets discipline per D8/D10/D11: PAT + DB_PASS + pooler URL all read via read_secret_to_env from lib.sh and handed to gbrain via GBRAIN_DATABASE_URL env, never argv. PAT carries the full D11 scope disclosure before collection and an explicit revocation reminder after success. D12 SIGINT recovery prints the in-flight ref + resume command. D18 MCP registration is scoped honestly to Claude Code — skips with a manual-register hint when `claude` is not on PATH. D6 per-remote trust-triad question (read-write/read-only/deny/skip-for-now) gates repo import; the triad values compose with the D2-eng schema-version policy file so future migrations stay deterministic. Skill runs concurrent-run-locked via mkdir ~/.gstack/.setup-gbrain.lock.d (atomic, same pattern as gstack-brain-sync). Telemetry (D4) payload carries enumerated categorical values only — never URL, PAT, or any postgresql:// substring. --repo, --switch, --resume-provision, --cleanup-orphans shortcut modes documented inline; the skill parses its own invocation args. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(health): integrate gbrain as D6 composite dimension Adds a GBrain row to the /health dashboard rubric with weight 10%. Three sub-signals rolled into one 0-10 score: doctor status (0.5), sync queue depth (0.3), last-push age (0.2). Redistributes when gbrain_sync_mode is off so the dimension stays fair. Weights rebalance: typecheck 25→22, lint 20→18, test 30→28, deadcode 15→13, shell 10→9, gbrain +10 — sums to 100. gbrain doctor --json wrapped in timeout 5s so a hung gbrain never stalls the /health dashboard. Dimension is omitted (not red) when gbrain is not installed — running /health on a non-gbrain machine shouldn't penalize that choice. History-JSONL adds a `gbrain` field. Pre-D6 entries read as null for trend comparison; new tracking starts from first post-D6 run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(test): add secret-sink-harness for negative-space leak testing (D21 #5) Runs a subprocess with a seeded secret, captures every channel the subprocess could leak through, and asserts the seed never appears. Built per the D1-eng tightened contract: per-run tmp $HOME, four seed match rules (exact + URL-decoded + first-12-char prefix + base64), fd-level stdout/stderr capture via Bun.spawn, post-mortem walk of every file written under $HOME, separate buckets for telemetry JSONL. Reusable: any future skill that handles secrets can import runWithSecretSink and run positive/negative controls against its own bins. The harness itself is ~180 lines of TS with no external deps beyond Bun + node:fs. Out of scope for v1 (documented as follow-ups): subprocess env dump (portable /proc reading), the user's real shell history (bins don't modify it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: secret-sink harness positive controls + real-bin negative controls 11 tests. Positive controls deliberately leak a seed in every covered channel (stdout, stderr, a file under $HOME, the telemetry JSONL path, base64-encoded, first-12-char prefix) and assert the harness catches each one. Without these, a harness that silently under-reports would look identical to a harness that works. Negative controls run real setup-gbrain bins with distinctive seeds: - supabase-verify rejects a mysql:// URL and a direct-connection URL, password never appears in any captured channel - lib.sh read_secret_to_env reads piped stdin, emits only the length, seed value stays invisible - supabase-provision on an auth-failure path fails fast without leaking the PAT to any channel Covers D21 #5 leak harness + uses it to validate D3-eng, D10, D11 discipline end-to-end on the already-shipped bins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(setup-gbrain): add list-orphans + delete-project subcommands (D20) Powers /setup-gbrain --cleanup-orphans. list-orphans filters the authenticated user's Supabase projects by name prefix (default "gbrain") and excludes the project the local ~/.gbrain/config.json currently points at, so only unclaimed gbrain-shaped projects come back. Active-ref detection parses the pooler URL's user portion (postgres.<ref>:<pw>@...). delete-project is a thin DELETE /v1/projects/{ref} wrapper with no confirmation of its own — the skill's UI layer owns the per-project confirm AskUserQuestion loop. Keeps responsibilities clean: the bin manages HTTP; the skill manages user intent. Both subcommands reuse the existing api_call retry+backoff and the same PAT discipline (env only, never argv). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(setup-gbrain): list-orphans active-ref filtering + delete-project 404 6 new tests bringing the supabase-provision suite to 28: list-orphans: - Filters to gbrain-prefixed projects, excludes the active-ref derived from ~/.gbrain/config.json's pooler URL - Treats all gbrain-prefixed projects as orphans when no config exists (first run on a new machine) - Respects custom --name-prefix for users who named their brain something else delete-project: - Happy path sends DELETE /v1/projects/<ref> and returns {deleted_ref} - 404 surfaces cleanly (exit 2, "404" in stderr) - Missing <ref> positional rejected with exit 2 Uses per-test tmpdir HOME with a stubbed ~/.gbrain/config.json so active-ref extraction runs against deterministic fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: regenerate setup-gbrain SKILL.md after main merge * chore: bump version and changelog (v1.12.0.0) Ships /setup-gbrain and its supporting infrastructure end-to-end: per-remote trust policy, installer with PATH-shadow guard, shared secret-read helper, structural URL verifier, Supabase Management API wrapper, /health GBrain dimension, secret-sink test harness. 100 new tests across 5 suites, all green. Three pre-existing test failures noted as P0 in TODOS.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add USING_GBRAIN_WITH_GSTACK.md + update README for /setup-gbrain README changes: - Rewrote the "Cross-machine memory with GBrain sync" section into "GBrain — persistent knowledge for your coding agent." Covers the three /setup-gbrain paths (Supabase existing URL, auto-provision, PGLite local), MCP registration, per-remote trust triad, and the (still-separate) memory sync feature. - Added /setup-gbrain row to the skills table pointing at the full guide. - Added /setup-gbrain to both skill-list install snippets. - Added USING_GBRAIN_WITH_GSTACK.md to the Docs table. New doc (USING_GBRAIN_WITH_GSTACK.md): - All three setup paths with trust-surface caveats - MCP registration details (and honest Claude-Code-v1 scoping) - Per-remote trust triad semantics + how to change a policy - Switching engines (PGLite ↔ Supabase) via --switch - GStack memory sync + its relationship to the gbrain knowledge base - /setup-gbrain --cleanup-orphans for orphan Supabase projects - Full command + flag reference, every bin helper, every env var - Security model: what's enforced in code, what's enforced by the leak harness, and the honest limits of v1 - Troubleshooting: PATH shadowing, direct-connection URL reject, auto-provision timeout, stale lock, policy file hand-edits, migrate hang - Why-this-design section explaining the non-obvious choices Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brain-sync): secret scanner now catches Bearer-prefixed auth tokens in JSON The bearer-token-json regex value charset was [A-Za-z0-9_./+=-]{16,}, which does NOT permit spaces. Real HTTP auth headers embed the scheme name with a literal space — "Bearer <token>" — so the value portion actually starts with "Bearer " and the existing regex couldn't match. Result: any JSON blob containing "authorization":"Bearer ..." would slip past the scanner and sync to the user's private brain repo with the bearer token inline. Added optional (Bearer |Basic |Token )? prefix in front of the value charset. Now matches the common auth-scheme forms without broadening the matcher to tolerate arbitrary whitespace (which would false-positive on lots of benign JSON). Verified against 5 positive cases (bearer-in-json, clean bearer, apikey no-prefix, token with Bearer, password no-prefix) + 3 negative cases (too-short tokens, non-secret field names like username, random JSON). This closes the P0 security regression first noticed during v1.12.0.0 /ship. brain-sync.test.ts now passes all 7 secret-scan fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: mock-gh integration tests for gstack-brain-init auto-create path 8 tests covering the gh-repo-create happy path that had zero coverage before. Existing brain-sync.test.ts always passes --remote <bare-url> to bypass gh entirely, so the interactive default ("press Enter, we'll run gh repo create for you") was shipping on trust. Test strategy: write a bash stub for gh that records every call into a file, then run gstack-brain-init with that stub on PATH. Assertions verify: gh auth status is checked, gh repo create fires with the computed gstack-brain-<user> default name + --private + --source flags, fall-through to gh repo view when create reports already-exists, user-provided URL bypasses gh entirely, gh-not-on-path and gh-not-authed branches both prompt for URL, --remote flag short-circuits all gh calls, conflicting-remote re-runs exit 1 with a clear message. No real GitHub, no live auth. Gate tier — runs on every commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): privacy-gate AskUserQuestion fires from preamble (periodic tier) Two periodic-tier E2E tests exercising the preamble's privacy gate end-to-end via the Agent SDK + canUseTool. Previously uncovered: - Positive: stages a fake gbrain on PATH + gbrain_sync_mode_prompted=false in config, runs a real skill, intercepts tool-use. Asserts the preamble fires a 3-option AskUserQuestion matching the canonical prose ("publish session memory" / "artifact" / "decline") and does NOT fire a second time in the same run (idempotency within session). - Negative: same staging but prompted=true. Asserts the gate stays silent even with gbrain detected on the host. Registered in test/helpers/touchfiles.ts as `brain-privacy-gate` (periodic) with dependency tracking on generate-brain-sync-block.ts, the three gstack-brain-* bins, gstack-config, and the Agent SDK runner. Diff-based selection re-runs the E2E when any of those change. Cost: ~$0.30-$0.50 per run. Only fires under EVALS=1 EVALS_TIER=periodic; gate tier stays free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update TODOS for bearer-json fix + new brain-sync test coverage Moves the bearer-json secret-scan regression from the P0 "pre-existing failures" block into the Completed section with full context on the fix, the mock-gh tests, the E2E privacy-gate tests, and the touchfile registration. Remaining P0s are the GSTACK_HOME config-isolation bug and the stale Opus 4.7 overlay pacing assertion, both unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): E2E privacy gate — ambient env + skill-file prompt Two fixes to get the E2E actually running end-to-end (first attempt failed at the SDK auth step, second at the assertion step): 1. Don't pass an explicit `env:` object to runAgentSdkTest. The SDK's auth pipeline misses ANTHROPIC_API_KEY when env is supplied as an object (verified against the plan-mode-no-op test, which passes no env and auths cleanly). Mutate process.env before the call instead, and restore the originals in finally so other tests don't inherit the ambient mutation. 2. The "Run /learn with no arguments" user prompt was too narrow — the model reduced it to a direct action and skipped the preamble privacy-gate directives entirely, so zero AskUserQuestions fired. Mirror the plan-mode-no-op pattern: point the model at the skill file on disk and ask it to follow every preamble directive. Bumped maxTurns from 6 to 10 to give the preamble room to execute. Verified both tests pass under `EVALS=1 EVALS_TIER=periodic bun test test/skill-e2e-brain-privacy-gate.test.ts` against a real ANTHROPIC_API_KEY. Cost per run: ~$0.30-$0.50 per test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(CLAUDE.md): source ANTHROPIC/OPENAI keys from ~/.zshrc for paid evals Conductor workspaces don't inherit the interactive shell env, so both API keys are absent from the default process env even though they're set in ~/.zshrc. Documents the source-from-zshrc pattern (grep + eval, never echo the value) plus the Agent SDK gotcha: do NOT pass env as an object to runAgentSdkTest — mutate process.env ambiently and restore in finally. Discovered this during the brain-privacy-gate E2E. First run failed at SDK auth with 401; second failed because explicit env handoff bypassed the SDK's own auth routing. Fix pattern now codified so the next paid-eval session in a Conductor workspace doesn't hit the same two dead ends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## [1.12.0.0] - 2026-04-24
|
||||
|
||||
## **`/setup-gbrain` — any coding agent goes from zero to "gbrain is running, and I can call it" in under five minutes.**
|
||||
|
||||
gstack v1.9.0.0 shipped `gbrain-sync`, which assumed a `gbrain` CLI was already installed. That was fine on Garry's machine (he'd manually cloned `~/git/gbrain`), broken for everyone else. This release closes the onboarding gap: one skill, three paths (local PGLite, existing Supabase URL, or Supabase auto-provision via the Management API), an MCP registration step for Claude Code, a per-remote trust triad (read-write / read-only / deny) so multi-client consultants don't mingle brains, and a reusable secret-sink test harness other skills can import when they start handling secrets.
|
||||
|
||||
### What shipped
|
||||
|
||||
Six new `bin/` helpers and one new skill template. `bin/gstack-gbrain-repo-policy` stores per-remote ingest tiers at `~/.gstack/gbrain-repo-policy.json` with a `_schema_version: 2` field so future migrations are deterministic (the first one — legacy `allow` → `read-write` — already runs on first read of any pre-D3 file). `bin/gstack-gbrain-detect` emits the full state as JSON so the skill can skip steps that are already done. `bin/gstack-gbrain-install` probes `~/git/gbrain` and `~/gbrain` before cloning fresh (fixes the day-one dup-clone footgun on the author's own machine) and fails hard on PATH shadowing with a three-option remediation menu instead of warn-and-continue. `bin/gstack-gbrain-lib.sh` extracts the `read_secret_to_env` helper used for both PAT collection and pooler-URL paste — one canonical implementation of the stty-echo-off + SIGINT-restore + env-var-only pattern. `bin/gstack-gbrain-supabase-verify` rejects direct-connection URLs (IPv6-only, fails in most environments) with exit code 3 so the caller's retry UX is distinct from a generic format error. `bin/gstack-gbrain-supabase-provision` wraps the Management API — list-orgs, create, poll, pooler-url, list-orphans, delete-project — with full HTTP error coverage (401/403/402/409/429/5xx), exponential backoff, and `--cleanup-orphans` support for the rare case where someone kills setup mid-provision.
|
||||
|
||||
The skill template itself threads these together into a single interactive flow. PAT collection shows the full scope disclosure verbatim before the read-s prompt, explains that the token grants access to every project in the user's Supabase account, and emits a revocation reminder at the end. Path 1's pooler-URL paste gets the same hygiene plus a redacted preview (host / port / database visible, password masked). Switching between engines wraps `gbrain migrate` in `timeout 180s` with an actionable message on deadlock. Concurrent-run protection via `mkdir ~/.gstack/.setup-gbrain.lock.d`. Telemetry records scenario, install result, MCP opt-in, trust tier — all enumerated categorical values, never free-form strings that could leak secrets.
|
||||
|
||||
`/health` gets a new GBrain dimension (weight 10%, wrapped in `timeout 5s`) alongside type-check / lint / tests / dead-code / shell-linter. The dimension is omitted — not red — when gbrain isn't installed, so running `/health` on a non-gbrain machine doesn't penalize that choice.
|
||||
|
||||
`test/helpers/secret-sink-harness.ts` is new infrastructure. Runs a subprocess with a seeded secret, captures stdout / stderr / files-under-HOME / telemetry-JSONL, and asserts the seed never appears in any channel via four match rules (exact + URL-decoded + first-12-char prefix + base64). Seven positive-control tests prove the harness catches leaks in every covered channel; four negative controls run real setup-gbrain bins with seeded secrets and confirm nothing escapes. Any future skill that handles secrets can import `runWithSecretSink` and run the same pattern.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: `bun test` against Slices 1–7's five new test files.
|
||||
|
||||
| Suite | Tests | Time |
|
||||
|---|---|---|
|
||||
| `gbrain-repo-policy.test.ts` | 24 | ~1.2s |
|
||||
| `gbrain-detect-install.test.ts` | 15 | ~1.0s |
|
||||
| `gbrain-lib-verify.test.ts` | 22 | ~0.2s |
|
||||
| `gbrain-supabase-provision.test.ts` | 28 | ~13.8s |
|
||||
| `secret-sink-harness.test.ts` | 11 | ~7.0s |
|
||||
| **Total** | **100** | **~23s** |
|
||||
|
||||
Every HTTP error path for the Supabase Management API is covered by a mock-server fixture. Every secret-bearing bin is exercised with a distinctive seed through the leak harness.
|
||||
|
||||
### What this means for Claude Code users
|
||||
|
||||
Previously: install gbrain manually, hope nothing was shadowing on PATH, paste the pooler URL into an echoing prompt, figure out MCP registration yourself. Now: one command, three paths, PAT-handled-correctly auto-provision, MCP registered for Claude Code automatically, trust tiers for multi-client work, leak-tested end-to-end. Run `/setup-gbrain`.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- `/setup-gbrain` skill (`setup-gbrain/SKILL.md.tmpl`) — full onboarding flow with path selection, PAT-scoped disclosure, redacted URL preview, concurrent-run lock, SIGINT recovery with `--resume-provision`, and `--cleanup-orphans` subcommand.
|
||||
- `bin/gstack-gbrain-repo-policy` — per-remote trust triad (read-write / read-only / deny), schema-versioned file format, atomic writes, corrupt-file quarantine.
|
||||
- `bin/gstack-gbrain-detect` — JSON state reporter for skill branching.
|
||||
- `bin/gstack-gbrain-install` — D5 detect-first installer, D19 PATH-shadow fail-hard validator, pinned gbrain commit.
|
||||
- `bin/gstack-gbrain-lib.sh` — shared `read_secret_to_env` bash helper.
|
||||
- `bin/gstack-gbrain-supabase-verify` — structural URL validator with distinct exit for direct-connection rejects.
|
||||
- `bin/gstack-gbrain-supabase-provision` — Management API wrapper (list-orgs / create / wait / pooler-url / list-orphans / delete-project) with full HTTP error coverage and retry+backoff.
|
||||
- `test/helpers/secret-sink-harness.ts` — reusable negative-space leak-testing harness.
|
||||
|
||||
#### Changed
|
||||
- `/health` skill adds a GBrain composite dimension (weight 10%, wrapped in `timeout 5s`). Existing category weights rebalanced to keep the composite score on the 0–10 scale; historical JSONL entries without a `gbrain` field read as `null` for trend comparison.
|
||||
|
||||
#### For contributors
|
||||
- Pre-Impl Gate 1 verified Supabase Management API shape before any code was written. Corrected two wrong endpoint assumptions (`POST /v1/projects` not `/v1/organizations/{ref}/projects`; `/config/database/pooler` not `/config/database`) and confirmed gbrain's `--non-interactive` + `GBRAIN_DATABASE_URL` env var are real. Documented in the plan file.
|
||||
- Review discipline: CEO review + Codex outside voice + Eng review all passed in plan mode before any code landed (3 reviews, 21 D-decisions, 0 unresolved gaps).
|
||||
|
||||
## [1.11.1.0] - 2026-04-23
|
||||
|
||||
## **Plan mode stopped silently rubber-stamping your reviews. The forcing questions actually fire now.**
|
||||
|
||||
@@ -26,6 +26,26 @@ bun run slop:diff # slop findings in files changed on this branch only
|
||||
|
||||
`test:evals` requires `ANTHROPIC_API_KEY`. Codex E2E tests (`test/codex-e2e.test.ts`)
|
||||
use Codex's own auth from `~/.codex/` config — no `OPENAI_API_KEY` env var needed.
|
||||
|
||||
**Where the keys live on this machine.** Conductor workspaces don't inherit the
|
||||
user's interactive shell env, so `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` aren't
|
||||
in the default process env. Before running any paid eval / E2E, source them from
|
||||
`~/.zshrc` (that's where Garry keeps them):
|
||||
|
||||
```bash
|
||||
bash -c '
|
||||
eval "$(grep -E "^export (ANTHROPIC_API_KEY|OPENAI_API_KEY)=" ~/.zshrc)"
|
||||
export ANTHROPIC_API_KEY OPENAI_API_KEY
|
||||
EVALS=1 EVALS_TIER=periodic bun test test/skill-e2e-<whatever>.test.ts
|
||||
'
|
||||
```
|
||||
|
||||
Do not echo the key value anywhere (stdout, logs, shell history). The grep+eval
|
||||
pattern keeps it in process env only. When passing to a test's Agent SDK, do NOT
|
||||
pass `env: {...}` to `runAgentSdkTest` — the SDK's auth pipeline doesn't pick up
|
||||
the key the same way when env is supplied as an object (confirmed failure mode).
|
||||
Instead, mutate `process.env.ANTHROPIC_API_KEY` ambiently before the call and
|
||||
restore in `finally`.
|
||||
E2E tests stream progress in real-time (tool-by-tool via `--output-format stream-json
|
||||
--verbose`). Results are persisted to `~/.gstack-dev/evals/` with auto-comparison
|
||||
against the previous run.
|
||||
|
||||
@@ -48,7 +48,7 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source
|
||||
|
||||
Open Claude Code and paste this. Claude does the rest.
|
||||
|
||||
> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it.
|
||||
> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /plan-devex-review, /devex-review, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn. Then ask the user if they also want to add gstack to the current project so teammates get it.
|
||||
|
||||
### Step 2: Team mode — auto-update for shared repos (recommended)
|
||||
|
||||
@@ -225,6 +225,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
||||
| `/unfreeze` | **Unlock** — remove the `/freeze` boundary. |
|
||||
| `/open-gstack-browser` | **GStack Browser** — launch GStack Browser with sidebar, anti-bot stealth, auto model routing (Sonnet for actions, Opus for analysis), one-click cookie import, and Claude Code integration. Clean up pages, take smart screenshots, edit CSS, and pass info back to your terminal. |
|
||||
| `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. |
|
||||
| `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). |
|
||||
| `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. |
|
||||
|
||||
### New binaries (v0.19)
|
||||
@@ -359,34 +360,39 @@ I open sourced how I build software. You can fork it and make it your own.
|
||||
> Come work at YC — [ycombinator.com/software](https://ycombinator.com/software)
|
||||
> Extremely competitive salary and equity. San Francisco, Dogpatch District.
|
||||
|
||||
## Cross-machine memory with GBrain sync
|
||||
## GBrain — persistent knowledge for your coding agent
|
||||
|
||||
gstack accumulates a lot of useful state on your laptop: learnings, CEO
|
||||
plans, design docs, retros, developer profile. Today, all of that dies when
|
||||
you switch machines. **GBrain sync** optionally pushes a curated, secret-scanned
|
||||
subset to a private git repo so your memory follows you, and (if you use
|
||||
GBrain) becomes indexable there.
|
||||
[GBrain](https://github.com/garrytan/gbrain) is a persistent knowledge base for AI agents — think of it as the memory your agent actually keeps between sessions. GStack gives you a one-command path from zero to "it's running, my agent can call it."
|
||||
|
||||
One command to turn it on:
|
||||
```bash
|
||||
/setup-gbrain
|
||||
```
|
||||
|
||||
Three paths, pick one:
|
||||
|
||||
- **Supabase, existing URL** — your cloud agent already provisioned a brain; paste the Session Pooler URL, now this laptop uses the same data.
|
||||
- **Supabase, auto-provision** — paste a Supabase Personal Access Token; the skill creates a new project, polls to healthy, fetches the pooler URL, hands it to `gbrain init`. ~90 seconds end-to-end.
|
||||
- **PGLite local** — zero accounts, zero network, ~30 seconds. Isolated brain on this Mac only. Great for try-first; migrate to Supabase later with `/setup-gbrain --switch`.
|
||||
|
||||
After init, the skill offers to register gbrain as an MCP server for Claude Code (`claude mcp add gbrain -- gbrain serve`) so `gbrain search`, `gbrain put_page`, etc. show up as first-class typed tools — not bash shell-outs.
|
||||
|
||||
**Per-remote trust policy.** Each repo on your machine gets one of three tiers:
|
||||
|
||||
- `read-write` — agent can search the brain AND write new pages back from this repo
|
||||
- `read-only` — agent can search but never writes (best for multi-client consultants: search the shared brain, don't contaminate it with Client A's work while in Client B's repo)
|
||||
- `deny` — no gbrain interaction at all
|
||||
|
||||
The skill asks once per repo. The decision is sticky across worktrees and branches of the same remote.
|
||||
|
||||
**GStack memory sync (different feature, same private-repo infra).** Optionally pushes your gstack state (learnings, CEO plans, design docs, retros, developer profile) to a private git repo so your memory follows you across machines, with a one-time privacy prompt (everything allowlisted / artifacts only / off) and a defense-in-depth secret scanner that blocks AWS keys, tokens, PEM blocks, and JWTs before they leave your machine.
|
||||
|
||||
```bash
|
||||
gstack-brain-init
|
||||
```
|
||||
|
||||
That creates a private GitHub repo (or any git remote you prefer —
|
||||
GitLab, Gitea, self-hosted). Every skill run syncs the queue at its
|
||||
start and end boundaries. No daemon, no background process. A one-time
|
||||
privacy prompt asks how much you want to share (everything allowlisted /
|
||||
artifacts only / off). Secret-shaped content (AWS keys, GitHub tokens,
|
||||
PEM blocks, JWTs, etc.) is blocked from sync before it leaves your
|
||||
machine.
|
||||
**Full monty — every scenario, every flag, every bin helper, every troubleshooting step:** [USING_GBRAIN_WITH_GSTACK.md](USING_GBRAIN_WITH_GSTACK.md)
|
||||
|
||||
New machine? Copy `~/.gstack-brain-remote.txt` over, run
|
||||
`gstack-brain-restore`, and yesterday's learnings surface on today's
|
||||
laptop.
|
||||
|
||||
Full guide: [docs/gbrain-sync.md](docs/gbrain-sync.md) •
|
||||
Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md)
|
||||
Other references: [docs/gbrain-sync.md](docs/gbrain-sync.md) (sync-specific guide) • [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md) (error index)
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -394,6 +400,7 @@ Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md)
|
||||
|-----|---------------|
|
||||
| [Skill Deep Dives](docs/skills.md) | Philosophy, examples, and workflow for every skill (includes Greptile integration) |
|
||||
| [Builder Ethos](ETHOS.md) | Builder philosophy: Boil the Lake, Search Before Building, three layers of knowledge |
|
||||
| [Using GBrain with GStack](USING_GBRAIN_WITH_GSTACK.md) | Every path, flag, bin helper, and troubleshooting step for `/setup-gbrain` |
|
||||
| [GBrain Sync](docs/gbrain-sync.md) | Cross-machine memory setup, privacy modes, troubleshooting |
|
||||
| [Architecture](ARCHITECTURE.md) | Design decisions and system internals |
|
||||
| [Browser Reference](BROWSER.md) | Full command reference for `/browse` |
|
||||
@@ -438,8 +445,8 @@ Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__*
|
||||
Available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review,
|
||||
/design-consultation, /design-shotgun, /design-html, /review, /ship, /land-and-deploy,
|
||||
/canary, /benchmark, /browse, /open-gstack-browser, /qa, /qa-only, /design-review,
|
||||
/setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex,
|
||||
/cso, /autoplan, /pair-agent, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn.
|
||||
/setup-browser-cookies, /setup-deploy, /setup-gbrain, /retro, /investigate, /document-release,
|
||||
/codex, /cso, /autoplan, /pair-agent, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, /learn.
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
## Testing
|
||||
|
||||
### Pre-existing test failures surfaced during v1.12.0.0 ship
|
||||
|
||||
**What:** Two remaining test failures on bare main that have been shipping as-is for multiple versions. (The bearer-json secret-scan regression flagged here originally was a real leak path and has been fixed in this PR — see Completed section below.)
|
||||
|
||||
1. `gstack-config gbrain keys > GSTACK_HOME overrides real config dir` (`test/brain-sync.test.ts:104`) — the GSTACK_HOME env override leaks into the real `~/.gstack/config.yaml`. Test asserts real config does NOT contain `gbrain_sync_mode: full` but it does. Either the test environment isn't isolated correctly or `bin/gstack-config` is writing to both locations.
|
||||
2. `Opus 4.7 overlay — pacing directive > keeps Fan out / Effort-match / Literal interpretation nudges` (`test/model-overlay-opus-4-7.test.ts:87`) — v1.10.1.0 (#1166) removed the "Fan out explicitly" nudge from the overlay but the assertion was never updated. Either the nudge should come back (intentional removal reverted) or the test should be updated to match the new expected content.
|
||||
|
||||
**Why:** Both have been green-washing through recent `/ship` runs via "pre-existing test failures skipped: <name>." #1 signals a real config isolation bug; #2 is a stale assertion since the overlay intentionally removed that nudge.
|
||||
|
||||
**Priority:** P0 (both)
|
||||
|
||||
**Effort:** S each. #1 likely a test harness fix in `test/brain-sync.test.ts`'s setup hook. #2 is a one-line test update OR a revert of #1166.
|
||||
|
||||
---
|
||||
|
||||
### `security-bench-haiku-responses.json` is 27MB, violates the 2MB tracked-file gate
|
||||
|
||||
**What:** `browse/test/fixtures/security-bench-haiku-responses.json` landed on main at v1.6.4.0 (PR #1135) at 27MB. The `no compiled binaries in git > git tracks no files larger than 2MB` gate in `test/skill-validation.test.ts:1623` fails on main and on every feature branch that merges main afterward.
|
||||
@@ -1304,6 +1319,17 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr
|
||||
|
||||
## Completed
|
||||
|
||||
### Bearer-token secret-scan regression fixed + E2E coverage added for privacy gate + gh auto-create (v1.12.0.0)
|
||||
|
||||
- **Fixed the `bearer-token-json` regression in `bin/gstack-brain-sync`** — the value charset `[A-Za-z0-9_./+=-]{16,}` didn't permit spaces, so auth headers with the standard `Bearer <token>` form (literal space after the scheme name) slipped past the scanner. Added an optional `(Bearer |Basic |Token )?` prefix to the pattern. Validated against 5 positive cases (including the regression fixture) + 3 negative cases (short tokens, non-secret keys, random JSON). The 7-pattern secret scanner now passes all fixtures including bearer-json.
|
||||
- **Added `test/gstack-brain-init-gh-mock.test.ts`** — 8 tests exercising the `gh` CLI auto-create path that previously had zero coverage. Stubs `gh` on PATH to record every call, asserts `gh repo create --private --description "..." --source <GSTACK_HOME>` fires with the computed `gstack-brain-<user>` default name. Covers: happy path, fall-through-to-`gh repo view` when create hits already-exists, user-provided-URL-bypasses-gh, gh-not-on-path prompts for URL, gh-not-authed prompts for URL, idempotent `--remote` re-runs, conflicting-remote rejection.
|
||||
- **Added `test/skill-e2e-brain-privacy-gate.test.ts`** — periodic-tier E2E (~$0.30-$0.50/run). Stages a fake `gbrain` on PATH + `gbrain_sync_mode_prompted=false` in config, runs a real skill via `runAgentSdkTest`, intercepts tool-use via `canUseTool`, and asserts the preamble fires the 3-option privacy AskUserQuestion with canonical prose ("publish session memory" / "artifact" / "decline"). Second test asserts the gate is silent when `prompted=true` (idempotency-within-session).
|
||||
- **Registered `brain-privacy-gate` in `test/helpers/touchfiles.ts`** (periodic tier) with dependency tracking on `scripts/resolvers/preamble/generate-brain-sync-block.ts`, `bin/gstack-brain-sync`, `bin/gstack-brain-init`, `bin/gstack-config`, and the Agent SDK runner. Diff-based selection will re-run the E2E whenever any of those change.
|
||||
|
||||
**Completed:** v1.12.0.0 (2026-04-24)
|
||||
|
||||
---
|
||||
|
||||
### Overlay efficacy harness + Opus 4.7 fanout nudge removal (v1.10.1.0)
|
||||
- Built `test/skill-e2e-overlay-harness.test.ts`, a parametric periodic-tier eval that drives `@anthropic-ai/claude-agent-sdk` and measures first-turn fanout rate (overlay-ON vs overlay-OFF) across registered fixtures
|
||||
- Measured the original "Fan out explicitly" overlay nudge: baseline Opus 4.7 = 70% first-turn fanout on toy prompt, with our nudge = 10%, with Anthropic's own canonical `<use_parallel_tool_calls>` text = 0%
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Using GBrain with GStack
|
||||
|
||||
Your coding agent, with a memory it actually keeps.
|
||||
|
||||
[GBrain](https://github.com/garrytan/gbrain) is a persistent knowledge base designed for AI agents. It stores what your agent learns, what you've decided, what worked and what didn't, and lets the agent search all of it on demand. GStack gives you a one-command path from zero to "gbrain is running, and my agent can call it" — with paths for try-it-local, share-with-your-team, and everything between.
|
||||
|
||||
This is the full monty: every scenario, every flag, every helper bin, every troubleshooting step. For the quick pitch, see the [README's GBrain section](README.md#gbrain--persistent-knowledge-for-your-coding-agent). For error codes and sync-specific issues, see [docs/gbrain-sync.md](docs/gbrain-sync.md).
|
||||
|
||||
---
|
||||
|
||||
## The one-command install
|
||||
|
||||
```bash
|
||||
/setup-gbrain
|
||||
```
|
||||
|
||||
That's it. The skill detects your current state, asks three questions at most, and walks you through install, init, MCP registration for Claude Code, and per-repo trust policy. On a clean Mac with nothing installed it finishes in under five minutes. On a Mac where something's already set up it takes seconds (it detects the existing state and skips done work).
|
||||
|
||||
## The three paths
|
||||
|
||||
You pick one when the skill asks "Where should your brain live?"
|
||||
|
||||
### Path 1: Supabase, you already have a connection string
|
||||
|
||||
Best for: you (or a teammate's cloud agent) already provisioned a Supabase brain and you want this local machine to use the same data.
|
||||
|
||||
**What happens:** Paste the Session Pooler URL (Settings → Database → Connection Pooler → Session → copy URI, port 6543). The skill reads it with echo off, shows you a redacted preview (`aws-0-us-east-1.pooler.supabase.com:6543/postgres` — host visible, password masked), hands it to `gbrain init` via the `GBRAIN_DATABASE_URL` environment variable, and the URL is never written to argv or your shell history.
|
||||
|
||||
**Trust warning:** Pasting this URL gives your local Claude Code full read/write access to every page in the shared brain. If that's not the trust level you want, pick PGLite local (Path 3) instead and accept the brains are disjoint.
|
||||
|
||||
### Path 2a: Supabase, auto-provision a new project
|
||||
|
||||
Best for: fresh Supabase account, you want a clean new project with zero clicking.
|
||||
|
||||
**What happens:** You paste a Supabase Personal Access Token (PAT). The skill shows you the scope disclosure first — *the token grants full access to every project in your Supabase account, not just the one we're about to create*. It lists your organizations, asks which one and which region (default `us-east-1`), generates a database password, calls `POST /v1/projects`, polls `GET /v1/projects/{ref}` every 5 seconds until the project is `ACTIVE_HEALTHY` (180s timeout), fetches the pooler URL, hands it to `gbrain init`. End-to-end: ~90 seconds.
|
||||
|
||||
At the end: explicit reminder to revoke the PAT at https://supabase.com/dashboard/account/tokens. The skill already discarded it from memory.
|
||||
|
||||
**If you Ctrl-C mid-provision:** The SIGINT trap prints your in-flight project ref + a resume command. You can delete the orphan at the Supabase dashboard, or run `/setup-gbrain --resume-provision <ref>` to pick up where you left off.
|
||||
|
||||
### Path 2b: Supabase, create manually
|
||||
|
||||
Best for: you'd rather click through supabase.com yourself than paste a PAT.
|
||||
|
||||
**What happens:** The skill walks you through the four manual steps (signup → new project → wait ~2 min → copy Session Pooler URL), then takes over from Path 1's paste step. Same security treatment as Path 1.
|
||||
|
||||
### Path 3: PGLite local
|
||||
|
||||
Best for: try-it-first, no account, no cloud, no sharing. Or a dedicated "this Mac's brain" that stays isolated from any cloud agent.
|
||||
|
||||
**What happens:** `gbrain init --pglite`. Brain lives at `~/.gbrain/brain.pglite`. No network calls. Done in 30 seconds.
|
||||
|
||||
This is the best first choice if you just want to see what gbrain feels like before committing to cloud. You can always migrate later with `/setup-gbrain --switch`.
|
||||
|
||||
## MCP registration for Claude Code
|
||||
|
||||
By default the skill asks "Give Claude Code a typed tool surface for gbrain?" If you say yes, it runs:
|
||||
|
||||
```bash
|
||||
claude mcp add gbrain -- gbrain serve
|
||||
```
|
||||
|
||||
That registers gbrain's stdio MCP server with Claude Code. Now `gbrain search`, `gbrain put_page`, `gbrain get_page`, etc. show up as first-class tools in every session, not bash shell-outs.
|
||||
|
||||
**If `claude` is not on PATH**, the skill skips MCP registration gracefully with a manual-register hint. The CLI resolver still works from any skill that shells out to `gbrain` — MCP is an upgrade, not a prerequisite.
|
||||
|
||||
**Other local agents** (Cursor, Codex CLI, etc.) need their own MCP registration. The skill is Claude-Code-targeted for v1; other hosts can register `gbrain serve` manually in their own MCP config.
|
||||
|
||||
## Per-remote trust policy (the triad)
|
||||
|
||||
Every repo on your machine gets a policy decision: **read-write**, **read-only**, or **deny**.
|
||||
|
||||
- **read-write** — your agent can `gbrain search` from this repo's context AND write new pages back to the brain. Default for your own projects.
|
||||
- **read-only** — your agent can search the brain but never writes new pages from this repo's sessions. Ideal for multi-client consultants: search the shared brain, don't contaminate it with Client A's code while you're in Client B's repo.
|
||||
- **deny** — no gbrain interaction at all. The repo is invisible to gbrain tooling.
|
||||
|
||||
The skill asks once per repo the first time you run a gstack skill there. After that the decision is sticky — every worktree + branch of the same git remote shares the same policy, so you set it once and it follows you.
|
||||
|
||||
SSH and HTTPS remote variants collapse to the same key: `https://github.com/foo/bar.git` and `git@github.com:foo/bar.git` are the same repo.
|
||||
|
||||
**To change a policy:**
|
||||
|
||||
```bash
|
||||
/setup-gbrain --repo # re-prompt for this repo only
|
||||
|
||||
# Or directly:
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy set "github.com/foo/bar" read-only
|
||||
```
|
||||
|
||||
**To see every policy:**
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy list
|
||||
```
|
||||
|
||||
Storage: `~/.gstack/gbrain-repo-policy.json`, mode 0600, schema-versioned so future migrations stay deterministic.
|
||||
|
||||
## Switching engines later
|
||||
|
||||
Picked PGLite and now want to join a team brain? One command:
|
||||
|
||||
```bash
|
||||
/setup-gbrain --switch
|
||||
```
|
||||
|
||||
The skill runs `gbrain migrate --to supabase --url "$URL"` wrapped in `timeout 180s`. Migration is bidirectional (Supabase → PGLite also works) and lossless — pages, chunks, embeddings, links, tags, and timeline all copy. Your original brain is preserved as a backup.
|
||||
|
||||
**If migration hangs:** another gstack session may be holding a lock on the source brain. The timeout fires at 3 minutes with an actionable message. Close other workspaces and re-run.
|
||||
|
||||
## GStack memory sync (a separate concern)
|
||||
|
||||
This is different from gbrain itself. Your gstack state (`~/.gstack/` — learnings, plans, retros, timeline, developer profile) is machine-local by default. "GStack memory sync" optionally pushes a curated, secret-scanned subset to a private git repo so your memory follows you across machines — and, if you're running gbrain, that git repo becomes indexable there too.
|
||||
|
||||
Turn it on with:
|
||||
|
||||
```bash
|
||||
gstack-brain-init
|
||||
```
|
||||
|
||||
You'll get a one-time privacy prompt: **everything allowlisted** / **artifacts only** (plans, designs, retros, learnings — skip behavioral data like timelines) / **off**. Every skill run syncs the queue at start and end — no daemon, no background process.
|
||||
|
||||
Secret-shaped content (AWS keys, GitHub tokens, PEM blocks, JWTs, bearer tokens) is blocked from sync before it leaves your machine.
|
||||
|
||||
**On a new machine:** Copy `~/.gstack-brain-remote.txt` over, run `gstack-brain-restore`, and yesterday's learnings surface on today's laptop.
|
||||
|
||||
Full guide: [docs/gbrain-sync.md](docs/gbrain-sync.md). Error index: [docs/gbrain-sync-errors.md](docs/gbrain-sync-errors.md).
|
||||
|
||||
`/setup-gbrain` offers to wire this up for you at the end of initial setup — it's one more AskUserQuestion, and it integrates with the same private-repo infrastructure.
|
||||
|
||||
## Cleanup orphan projects
|
||||
|
||||
If you Ctrl-C'd mid-provision, tried three different names before settling on one, or otherwise accumulated gbrain-shaped Supabase projects you don't use, there's a subcommand for that:
|
||||
|
||||
```bash
|
||||
/setup-gbrain --cleanup-orphans
|
||||
```
|
||||
|
||||
The skill re-collects a PAT (one-time, discarded after), lists every project in your Supabase account whose name starts with `gbrain` and whose ref doesn't match your active `~/.gbrain/config.json` pooler URL. For each orphan it asks per-project: *"Delete orphan project `<ref>` (`<name>`, created `<date>`)?"* — no batching, no "delete all" shortcut. The active brain is never offered for deletion.
|
||||
|
||||
## Command + flag reference
|
||||
|
||||
### `/setup-gbrain` entry modes
|
||||
|
||||
| Invocation | What it does |
|
||||
|---|---|
|
||||
| `/setup-gbrain` | Full flow: detect state, pick path, install, init, MCP, policy, optional memory-sync |
|
||||
| `/setup-gbrain --repo` | Flip the per-remote trust policy for the current repo only |
|
||||
| `/setup-gbrain --switch` | Migrate engine (PGLite ↔ Supabase) without re-running the other steps |
|
||||
| `/setup-gbrain --resume-provision <ref>` | Resume a path-2a auto-provision that was interrupted during polling |
|
||||
| `/setup-gbrain --cleanup-orphans` | List + per-project delete of orphan Supabase projects |
|
||||
|
||||
### Bin helpers (for scripting)
|
||||
|
||||
| Bin | Purpose |
|
||||
|---|---|
|
||||
| `gstack-gbrain-detect` | Emit current state as JSON: gbrain on PATH, version, config engine, doctor status, sync mode |
|
||||
| `gstack-gbrain-install` | Detect-first installer (probes `~/git/gbrain`, `~/gbrain`, then fresh clone). Has `--dry-run` and `--validate-only` flags. PATH-shadow check exits 3 with remediation menu. |
|
||||
| `gstack-gbrain-lib.sh` | Sourced, not executed. Provides `read_secret_to_env VARNAME "prompt" [--echo-redacted "<sed-expr>"]` |
|
||||
| `gstack-gbrain-supabase-verify` | Structural URL check. Rejects direct-connection URLs (`db.*.supabase.co:5432`) with exit 3 |
|
||||
| `gstack-gbrain-supabase-provision` | Management API wrapper. Subcommands: `list-orgs`, `create`, `wait`, `pooler-url`, `list-orphans`, `delete-project`. All require `SUPABASE_ACCESS_TOKEN` in env. `create` and `pooler-url` also require `DB_PASS`. `--json` mode available on every subcommand. |
|
||||
| `gstack-gbrain-repo-policy` | Per-remote trust triad. Subcommands: `get`, `set`, `list`, `normalize` |
|
||||
|
||||
### gbrain CLI (upstream tool)
|
||||
|
||||
Gbrain itself ships with these that gstack wraps:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `gbrain init --pglite` | Initialize a local PGLite brain |
|
||||
| `gbrain init --non-interactive` | Initialize via env (`GBRAIN_DATABASE_URL` or `DATABASE_URL`). Never pass a URL as argv — it'll leak to shell history. |
|
||||
| `gbrain doctor --json` | Health check. Returns `{status: "ok"|"warnings"|"error", health_score: 0-100, checks: [...]}` |
|
||||
| `gbrain migrate --to supabase --url ...` | Move a PGLite brain to Supabase (lossless, preserves source as backup) |
|
||||
| `gbrain migrate --to pglite` | Reverse migration |
|
||||
| `gbrain search "query"` | Search the brain |
|
||||
| `gbrain put_page --title "..." --tags "a,b" <<<"content"` | Write a page |
|
||||
| `gbrain get_page "<slug>"` | Fetch a page |
|
||||
| `gbrain serve` | Start the MCP stdio server (used by `claude mcp add`) |
|
||||
|
||||
### Config files + state
|
||||
|
||||
| Path | What lives there |
|
||||
|---|---|
|
||||
| `~/.gbrain/config.json` | Engine (pglite/postgres), database URL or path, API keys. Mode 0600. Written by `gbrain init`. |
|
||||
| `~/.gstack/gbrain-repo-policy.json` | Per-remote trust triad. Schema v2. Mode 0600. |
|
||||
| `~/.gstack/.setup-gbrain.lock.d` | Concurrent-run lock (atomic mkdir). Released on normal exit + SIGINT. |
|
||||
| `~/.gstack/.brain-queue.jsonl` | Pending sync entries for gstack memory sync |
|
||||
| `~/.gstack/.brain-last-push` | Timestamp of last sync push (for `/health` scoring) |
|
||||
| `~/.gstack-brain-remote.txt` | URL of your gstack memory sync remote (safe to copy between machines) |
|
||||
| `~/.gstack/.setup-gbrain-inflight.json` | Reserved for future `--resume-provision` persisted state |
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Var | Where it's read | What it does |
|
||||
|---|---|---|
|
||||
| `SUPABASE_ACCESS_TOKEN` | `gstack-gbrain-supabase-provision` | PAT for Management API calls. Discarded after each setup run. |
|
||||
| `DB_PASS` | `gstack-gbrain-supabase-provision` (create, pooler-url) | Generated DB password. Never in argv. |
|
||||
| `GBRAIN_DATABASE_URL` | `gbrain init`, `gbrain doctor`, etc. | Postgres connection string (Supabase pooler URL for us). Env takes precedence over `~/.gbrain/config.json`. |
|
||||
| `DATABASE_URL` | `gbrain init` (fallback) | Same semantics as `GBRAIN_DATABASE_URL`; checked second. |
|
||||
| `SUPABASE_API_BASE` | `gstack-gbrain-supabase-provision` | Override the Management API host. Used by tests to point at a mock server. |
|
||||
| `GBRAIN_INSTALL_DIR` | `gstack-gbrain-install` | Override default install path (`~/gbrain`) |
|
||||
| `GSTACK_HOME` | every bin helper | Override `~/.gstack` state dir. Heavy test use. |
|
||||
|
||||
## Security model
|
||||
|
||||
One rule for every secret this skill touches: **env var only, never argv, never logged, never written to disk by us.** The only persistent storage is gbrain's own `~/.gbrain/config.json` at mode 0600, which is gbrain's discipline, not ours.
|
||||
|
||||
**Enforced in code:**
|
||||
|
||||
- CI grep test in `test/skill-validation.test.ts` fails the build if `$SUPABASE_ACCESS_TOKEN` or `$GBRAIN_DATABASE_URL` appears in an argv position
|
||||
- CI grep test fails if `--insecure`, `-k`, or `NODE_TLS_REJECT_UNAUTHORIZED=0` appear in `bin/gstack-gbrain-supabase-provision`
|
||||
- `set +x` at the top of the provision helper prevents debug tracing from leaking PAT
|
||||
- Telemetry payload contains only enumerated categorical values (scenario, install result, MCP opt-in, trust tier) — never free-form strings that could contain secrets
|
||||
|
||||
**Enforced via tests:**
|
||||
|
||||
- `test/secret-sink-harness.test.ts` runs every secret-handling bin with a seeded secret and asserts the seed never appears in any captured channel (stdout, stderr, files under `$HOME`, telemetry JSONL). Four match rules per seed: exact, URL-decoded, first-12-char prefix, base64.
|
||||
- Positive controls in the same test file deliberately leak seeds in every covered channel and assert the harness catches each one. Without the positive controls, a harness that silently under-reports would look identical to a working harness.
|
||||
|
||||
**What you can still leak** (the honest limits of v1):
|
||||
|
||||
- If you paste a secret into a normal chat message outside `read -s`, it's in the conversation transcript and any host-side logging
|
||||
- The leak harness doesn't dump subprocess environment — a bin that `env >> ~/.log` would evade detection (no bin in v1 does this; grep tests prevent it)
|
||||
- Your shell's own `HISTFILE` behavior is your shell's, not ours — we never pass secrets to argv so they don't land there via our code, but nothing stops you from pasting one into a raw `curl` command yourself
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "PATH SHADOWING DETECTED" during install
|
||||
|
||||
Another `gbrain` binary is earlier in PATH than the one the installer just linked. The installer's version check caught it. Fix one of:
|
||||
|
||||
- `rm $(which gbrain)` if you don't need the other one
|
||||
- Prepend `~/.bun/bin` to PATH in your shell rc so the linked binary wins
|
||||
- Set `GBRAIN_INSTALL_DIR` to the shadowing binary's install directory and re-run
|
||||
|
||||
Then re-run `/setup-gbrain`.
|
||||
|
||||
### "rejected direct-connection URL"
|
||||
|
||||
You pasted a `db.<ref>.supabase.co:5432` URL. Those are IPv6-only and fail in most environments. Use the Session Pooler URL instead: Supabase dashboard → Settings → Database → Connection Pooler → **Session** → copy URI (port 6543).
|
||||
|
||||
### Auto-provision times out at 180s
|
||||
|
||||
The Supabase project is still initializing. Your ref was printed in the exit message. Wait a minute, then:
|
||||
|
||||
```bash
|
||||
/setup-gbrain --resume-provision <ref>
|
||||
```
|
||||
|
||||
The skill re-collects a PAT, skips project creation, resumes polling.
|
||||
|
||||
### "Another `/setup-gbrain` instance is running"
|
||||
|
||||
You have a stale lock directory. If you're sure no other instance is actually running:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.gstack/.setup-gbrain.lock.d
|
||||
```
|
||||
|
||||
Then re-run.
|
||||
|
||||
### "No cross-model tension" on policy file
|
||||
|
||||
You edited `~/.gstack/gbrain-repo-policy.json` by hand with legacy `allow` values? No problem. On the next read, gstack auto-migrates `allow` → `read-write` and adds `_schema_version: 2`. One log line on stderr, idempotent, deterministic.
|
||||
|
||||
### `gbrain doctor` says "warnings"
|
||||
|
||||
`/health` treats that as yellow, not red. Check `gbrain doctor --json | jq .checks` to see which sub-checks are warning. Typical causes: resolver MECE overlap (skill names clashing) or DB connection not yet configured.
|
||||
|
||||
### Switching PGLite → Supabase hangs
|
||||
|
||||
Another gstack session in a sibling Conductor workspace may be holding a lock on your local PGLite file via its preamble's `gstack-brain-sync` call. Close other workspaces, re-run `/setup-gbrain --switch`. The timeout is bounded at 180s so you'll never actually wait forever.
|
||||
|
||||
## Why this design
|
||||
|
||||
**Why per-remote trust triad and not binary allow/deny?** Multi-client consultants need search without write-back. A freelance dev working on Client A in the morning and Client B in the afternoon can't let A's code insights leak into a brain Client B can search. Read-only solves that cleanly.
|
||||
|
||||
**Why not bundle gbrain into gstack?** Gbrain is a separate, actively-developed project with its own release cadence, schema migrations, and MCP surface. Bundling would mean gstack has to gate gbrain updates, which slows gbrain improvements from reaching users. Separate-but-integrated lets each ship on its own cadence.
|
||||
|
||||
**Why `gbrain init --non-interactive` via env var and not a flag?** Connection strings contain database passwords. Passing them as argv lands the password in `ps`, shell history, and process listings. Env-var handoff keeps the secret in process memory only. Gbrain supports both `GBRAIN_DATABASE_URL` and `DATABASE_URL`; we use the former to avoid collisions with non-gbrain tooling.
|
||||
|
||||
**Why fail-hard on PATH shadowing instead of warn-and-continue?** A shadowed `gbrain` means every subsequent command calls a different binary than the one we just installed. That's a silent version-drift bug that surfaces as mysterious feature gaps weeks later. Setup skills have one job — set up a working environment. Refusing to install into a broken one is the setup-skill-correct behavior.
|
||||
|
||||
**Why not auto-import every repo?** Privacy + noise. An auto-import preamble hook that ingests every repo you touch would: (a) leak work code into a shared brain without consent, and (b) clog search with throwaway repos. The per-remote policy makes ingestion an explicit, per-repo decision. `/setup-gbrain` doesn't install any auto-import hook today — but the policy store is forward-compatible for one later.
|
||||
|
||||
## Related skills + next steps
|
||||
|
||||
- `/health` — includes a GBrain dimension (doctor status, sync queue depth, last-push age) in its 0-10 composite score. The dimension is omitted when gbrain isn't installed; running `/health` on a non-gbrain machine doesn't penalize that choice.
|
||||
- `/gstack-upgrade` — keeps gstack itself up to date. Does NOT upgrade gbrain independently. To bump gbrain, update `PINNED_COMMIT` in `bin/gstack-gbrain-install` and re-run `/setup-gbrain`.
|
||||
- `/retro` — weekly retrospective pulls learnings and plans from your gbrain when memory sync is on, letting the retro reference cross-machine history.
|
||||
|
||||
Run `/setup-gbrain` and see what sticks.
|
||||
@@ -88,7 +88,12 @@ patterns = [
|
||||
('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')),
|
||||
('jwt', re.compile(r'\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b')),
|
||||
('bearer-token-json',
|
||||
re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\\s*:\\s*\"[A-Za-z0-9_./+=-]{16,}\"',
|
||||
# JSON-embedded auth headers. The optional Bearer/Basic/Token prefix
|
||||
# matters: real auth values include a literal space after the scheme
|
||||
# name, but the value charset below does not include spaces, so
|
||||
# without the optional prefix every Bearer token in a JSON blob slips
|
||||
# past the scanner.
|
||||
re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\\s*:\\s*\"(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}\"',
|
||||
re.IGNORECASE)),
|
||||
]
|
||||
text = sys.stdin.read()
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-gbrain-detect
|
||||
#
|
||||
# Output (always valid JSON, even when every check is false):
|
||||
# {
|
||||
# "gbrain_on_path": true|false,
|
||||
# "gbrain_version": "0.18.2" | null,
|
||||
# "gbrain_config_exists": true|false,
|
||||
# "gbrain_engine": "pglite"|"postgres" | null,
|
||||
# "gbrain_doctor_ok": true|false,
|
||||
# "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
|
||||
# "gstack_brain_git": true|false
|
||||
# }
|
||||
#
|
||||
# The /setup-gbrain skill reads this once at startup to decide which path
|
||||
# branches are live and which steps can be skipped. Never modifies state;
|
||||
# pure introspection. Exits 0 unless `jq` is missing.
|
||||
#
|
||||
# Env:
|
||||
# GSTACK_HOME — override ~/.gstack for gstack-brain-* state lookups.
|
||||
set -euo pipefail
|
||||
|
||||
STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONFIG_BIN="$SCRIPT_DIR/gstack-config"
|
||||
GBRAIN_CONFIG="$HOME/.gbrain/config.json"
|
||||
|
||||
die() { echo "gstack-gbrain-detect: $*" >&2; exit 2; }
|
||||
|
||||
require_jq() {
|
||||
command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq"
|
||||
}
|
||||
require_jq
|
||||
|
||||
# --- gbrain binary presence + version ---
|
||||
gbrain_on_path=false
|
||||
gbrain_version=null
|
||||
if command -v gbrain >/dev/null 2>&1; then
|
||||
gbrain_on_path=true
|
||||
# Format versions as JSON strings; gbrain --version may print other chatter.
|
||||
v=$(gbrain --version 2>/dev/null | head -1 | tr -d '[:space:]' || true)
|
||||
if [ -n "$v" ]; then
|
||||
gbrain_version=$(jq -Rn --arg v "$v" '$v')
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- gbrain config file ---
|
||||
gbrain_config_exists=false
|
||||
gbrain_engine=null
|
||||
if [ -f "$GBRAIN_CONFIG" ]; then
|
||||
gbrain_config_exists=true
|
||||
# Engine is defensively parsed; an invalid config returns null, not a crash.
|
||||
engine_raw=$(jq -r '.engine // empty' "$GBRAIN_CONFIG" 2>/dev/null || true)
|
||||
case "$engine_raw" in
|
||||
pglite|postgres) gbrain_engine=$(jq -Rn --arg e "$engine_raw" '$e') ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- gbrain doctor health ---
|
||||
# Doctor is wrapped in `timeout 5s` to match the /health D6 pattern and avoid
|
||||
# the detect step hanging the skill when gbrain is broken or its DB is
|
||||
# unreachable. Any nonzero exit or non-"ok"/"warnings" status → false.
|
||||
gbrain_doctor_ok=false
|
||||
if [ "$gbrain_on_path" = "true" ]; then
|
||||
# Use `timeout` if available; some minimal macs use gtimeout from coreutils.
|
||||
timeout_bin=""
|
||||
if command -v timeout >/dev/null 2>&1; then timeout_bin="timeout 5s"
|
||||
elif command -v gtimeout >/dev/null 2>&1; then timeout_bin="gtimeout 5s"
|
||||
fi
|
||||
if doctor_json=$(eval "$timeout_bin gbrain doctor --json" 2>/dev/null); then
|
||||
status=$(echo "$doctor_json" | jq -r '.status // empty' 2>/dev/null || true)
|
||||
case "$status" in
|
||||
ok|warnings) gbrain_doctor_ok=true ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- gstack-brain-sync state (memory sync, separate from gbrain itself) ---
|
||||
gstack_brain_sync_mode="off"
|
||||
if [ -x "$CONFIG_BIN" ]; then
|
||||
mode=$("$CONFIG_BIN" get gbrain_sync_mode 2>/dev/null || true)
|
||||
case "$mode" in
|
||||
off|artifacts-only|full) gstack_brain_sync_mode="$mode" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
gstack_brain_git=false
|
||||
if [ -d "$STATE_DIR/.git" ]; then
|
||||
gstack_brain_git=true
|
||||
fi
|
||||
|
||||
# Emit single-object JSON.
|
||||
jq -n \
|
||||
--argjson on_path "$gbrain_on_path" \
|
||||
--argjson version "$gbrain_version" \
|
||||
--argjson config_exists "$gbrain_config_exists" \
|
||||
--argjson engine "$gbrain_engine" \
|
||||
--argjson doctor_ok "$gbrain_doctor_ok" \
|
||||
--arg sync_mode "$gstack_brain_sync_mode" \
|
||||
--argjson brain_git "$gstack_brain_git" \
|
||||
'{
|
||||
gbrain_on_path: $on_path,
|
||||
gbrain_version: $version,
|
||||
gbrain_config_exists: $config_exists,
|
||||
gbrain_engine: $engine,
|
||||
gbrain_doctor_ok: $doctor_ok,
|
||||
gstack_brain_sync_mode: $sync_mode,
|
||||
gstack_brain_git: $brain_git
|
||||
}'
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-install — install the gbrain CLI on a local Mac.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-gbrain-install [--install-dir <dir>] [--pinned-commit <sha>] [--dry-run]
|
||||
#
|
||||
# D5 detect-first: before cloning anywhere, probe likely pre-existing
|
||||
# locations (~/git/gbrain and ~/gbrain) and reuse a working clone if one
|
||||
# exists. Falls back to a fresh clone of the pinned commit at ~/gbrain
|
||||
# (override with GBRAIN_INSTALL_DIR or --install-dir).
|
||||
#
|
||||
# D19 PATH-shadowing: after `bun link`, compare `gbrain --version` output
|
||||
# to the install-dir's package.json version. On mismatch, abort with an
|
||||
# actionable error listing every gbrain on PATH. Never "silently fixes"
|
||||
# PATH; setup skills should refuse broken environments.
|
||||
#
|
||||
# Prerequisites (checked before doing anything):
|
||||
# - bun (install: curl -fsSL https://bun.sh/install | bash)
|
||||
# - git
|
||||
# - network reachability to https://github.com
|
||||
#
|
||||
# The pinned commit is declared here rather than resolved dynamically so
|
||||
# upgrades are explicit and reviewable. Update PINNED_COMMIT when gstack
|
||||
# verifies compatibility with a new gbrain release.
|
||||
#
|
||||
# Env:
|
||||
# GBRAIN_INSTALL_DIR — override default install path (~/gbrain)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — success (or --dry-run printed the plan)
|
||||
# 2 — prerequisite missing or invalid argument
|
||||
# 3 — post-install validation failed (PATH shadow, broken binary, etc.)
|
||||
set -euo pipefail
|
||||
|
||||
# --- defaults ---
|
||||
PINNED_COMMIT="08b3698e90532b7b66c445e6b1d8cdfe71822802" # gbrain v0.18.2
|
||||
PINNED_TAG="v0.18.2"
|
||||
GBRAIN_REPO_URL="https://github.com/garrytan/gbrain.git"
|
||||
DEFAULT_INSTALL_DIR="${GBRAIN_INSTALL_DIR:-$HOME/gbrain}"
|
||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||
DRY_RUN=false
|
||||
VALIDATE_ONLY=false
|
||||
|
||||
die() { echo "gstack-gbrain-install: $*" >&2; exit 2; }
|
||||
fail() { echo "gstack-gbrain-install: $*" >&2; exit 3; }
|
||||
log() { echo "gstack-gbrain-install: $*"; }
|
||||
|
||||
# --- parse args ---
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--pinned-commit) PINNED_COMMIT="$2"; PINNED_TAG=""; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--validate-only) VALIDATE_ONLY=true; shift ;;
|
||||
--help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) die "unknown flag: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- prerequisites ---
|
||||
check_prereq() {
|
||||
local bin="$1"
|
||||
local hint="$2"
|
||||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||
fail "required tool '$bin' not found. $hint"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! $VALIDATE_ONLY; then
|
||||
check_prereq bun "Install: curl -fsSL https://bun.sh/install | bash"
|
||||
check_prereq git "Install: xcode-select --install (macOS) or your package manager"
|
||||
|
||||
# GitHub reachability — fail fast if offline rather than hanging `git clone`.
|
||||
# --max-time 10, --head (no body), quiet. Status code 200-4xx means we reached
|
||||
# the server (even 404 is reachability proof).
|
||||
if ! curl -s --head --max-time 10 https://github.com >/dev/null 2>&1; then
|
||||
fail "cannot reach https://github.com. Check your network and try again."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- D5 detect-first: probe common locations before cloning fresh ---
|
||||
# Accept any directory that looks like a gbrain clone: has package.json
|
||||
# with name "gbrain" and a `bin.gbrain` entry. Don't accept version mismatches
|
||||
# here — we'll let bun link run and then D19-validate.
|
||||
is_valid_clone() {
|
||||
local dir="$1"
|
||||
[ -d "$dir" ] || return 1
|
||||
[ -f "$dir/package.json" ] || return 1
|
||||
local name
|
||||
name=$(jq -r '.name // empty' "$dir/package.json" 2>/dev/null || true)
|
||||
[ "$name" = "gbrain" ] || return 1
|
||||
local bin
|
||||
bin=$(jq -r '.bin.gbrain // empty' "$dir/package.json" 2>/dev/null || true)
|
||||
[ -n "$bin" ] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
DETECTED_CLONE=""
|
||||
if ! $VALIDATE_ONLY; then
|
||||
for candidate in "$HOME/git/gbrain" "$HOME/gbrain" "$INSTALL_DIR"; do
|
||||
if is_valid_clone "$candidate"; then
|
||||
DETECTED_CLONE="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if $VALIDATE_ONLY; then
|
||||
log "validate-only mode: skipping detect + clone + install + link"
|
||||
elif [ -n "$DETECTED_CLONE" ]; then
|
||||
log "detected existing gbrain clone at $DETECTED_CLONE — reusing"
|
||||
INSTALL_DIR="$DETECTED_CLONE"
|
||||
else
|
||||
# Fresh clone path.
|
||||
if $DRY_RUN; then
|
||||
log "DRY RUN: would clone $GBRAIN_REPO_URL @ $PINNED_COMMIT → $INSTALL_DIR"
|
||||
exit 0
|
||||
fi
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
fail "install dir $INSTALL_DIR exists but is not a valid gbrain clone. Remove it or pass --install-dir <other>."
|
||||
fi
|
||||
log "cloning $GBRAIN_REPO_URL → $INSTALL_DIR"
|
||||
git clone --quiet "$GBRAIN_REPO_URL" "$INSTALL_DIR"
|
||||
( cd "$INSTALL_DIR" && git checkout --quiet "$PINNED_COMMIT" )
|
||||
log "pinned to $PINNED_COMMIT${PINNED_TAG:+ ($PINNED_TAG)}"
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
log "DRY RUN: would run bun install + bun link in $INSTALL_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- install + link ---
|
||||
if ! $VALIDATE_ONLY; then
|
||||
log "running bun install in $INSTALL_DIR"
|
||||
( cd "$INSTALL_DIR" && bun install --silent )
|
||||
log "running bun link in $INSTALL_DIR"
|
||||
( cd "$INSTALL_DIR" && bun link --silent )
|
||||
fi
|
||||
|
||||
# --- D19 PATH-shadowing validation ---
|
||||
# Read the version from the install-dir's package.json; compare to
|
||||
# `gbrain --version`. If they disagree, PATH is returning a DIFFERENT
|
||||
# gbrain than the one we just linked. Fail hard with remediation.
|
||||
expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true)
|
||||
if [ -z "$expected_version" ]; then
|
||||
fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)"
|
||||
fi
|
||||
|
||||
if ! command -v gbrain >/dev/null 2>&1; then
|
||||
fail "bun link completed but 'gbrain' is not on PATH. Ensure ~/.bun/bin is in your PATH."
|
||||
fi
|
||||
|
||||
actual_version=$(gbrain --version 2>/dev/null | head -1 | tr -d '[:space:]' || true)
|
||||
if [ -z "$actual_version" ]; then
|
||||
fail "gbrain is on PATH but 'gbrain --version' produced no output — the binary may be broken."
|
||||
fi
|
||||
|
||||
# Tolerate a leading "v" (gbrain may print either "0.18.2" or "v0.18.2").
|
||||
expected_norm="${expected_version#v}"
|
||||
actual_norm="${actual_version#v}"
|
||||
|
||||
if [ "$actual_norm" != "$expected_norm" ]; then
|
||||
echo "" >&2
|
||||
echo "gstack-gbrain-install: PATH SHADOWING DETECTED" >&2
|
||||
echo "" >&2
|
||||
echo " We just linked gbrain $expected_version from $INSTALL_DIR," >&2
|
||||
echo " but PATH is returning gbrain $actual_version." >&2
|
||||
echo "" >&2
|
||||
echo " All gbrain binaries on PATH:" >&2
|
||||
type -a gbrain 2>&1 | sed 's/^/ /' >&2 || true
|
||||
echo "" >&2
|
||||
echo " Fix one of the following, then re-run /setup-gbrain:" >&2
|
||||
echo " a) rm the shadowing binary: rm \$(which gbrain)" >&2
|
||||
echo " b) prepend ~/.bun/bin to PATH in your shell rc" >&2
|
||||
echo " c) point GBRAIN_INSTALL_DIR at the shadowing binary's install dir" >&2
|
||||
echo "" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
log "installed gbrain $actual_version from $INSTALL_DIR"
|
||||
echo ""
|
||||
echo "Next: gbrain init --pglite (or run /setup-gbrain for the full setup flow)"
|
||||
@@ -0,0 +1,101 @@
|
||||
# gstack-gbrain-lib.sh — shared helpers for setup-gbrain bin scripts.
|
||||
#
|
||||
# This file is NOT executable; source it:
|
||||
#
|
||||
# . "$(dirname "$0")/gstack-gbrain-lib.sh"
|
||||
#
|
||||
# Provides:
|
||||
# read_secret_to_env <VARNAME> <prompt> [--echo-redacted <sed-expr>]
|
||||
# — Read a secret from stdin into the named env var without echoing
|
||||
# to the terminal. On SIGINT/SIGTERM/EXIT, restores terminal echo so
|
||||
# future keystrokes are visible. Optionally emits a redacted preview
|
||||
# of what was read so the user can visually confirm they pasted the
|
||||
# right thing.
|
||||
#
|
||||
# stdin handling: when stdin is a TTY, stty -echo suppresses echo
|
||||
# while the user types. When stdin is piped (automated tests), the
|
||||
# stty calls are skipped — piping into `read` is already invisible.
|
||||
#
|
||||
# Var name must match [A-Z_][A-Z0-9_]* to prevent injection via
|
||||
# `read -r "$varname"` expansion. Invalid names abort.
|
||||
#
|
||||
# Exported after read so sub-processes inherit the secret. Caller
|
||||
# is responsible for `unset <VARNAME>` when done.
|
||||
#
|
||||
# Load-bearing for D3-eng (shared secret helper across PAT + URL paste),
|
||||
# D10 (env-var handoff, never argv), D11 (PAT scope disclosure + SIGINT
|
||||
# restore), D16 (pooler URL paste hygiene with redacted preview).
|
||||
|
||||
# _gstack_gbrain_validate_varname <name> — returns 0 if usable, 2 otherwise.
|
||||
_gstack_gbrain_validate_varname() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
[A-Z_][A-Z0-9_]*) return 0 ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_secret_to_env() {
|
||||
local varname="" prompt="" redact_expr=""
|
||||
# Parse leading positional args (varname, prompt), then optional flags.
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "read_secret_to_env: usage: read_secret_to_env <VARNAME> <prompt> [--echo-redacted <sed-expr>]" >&2
|
||||
return 2
|
||||
fi
|
||||
varname="$1"; shift
|
||||
prompt="$1"; shift
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--echo-redacted) redact_expr="$2"; shift 2 ;;
|
||||
*) echo "read_secret_to_env: unknown flag: $1" >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! _gstack_gbrain_validate_varname "$varname"; then
|
||||
echo "read_secret_to_env: invalid var name '$varname' (must match [A-Z_][A-Z0-9_]*)" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# stty manipulation only makes sense when stdin is a terminal. In CI /
|
||||
# test / piped contexts we skip it — piped input doesn't echo anyway.
|
||||
local is_tty=false
|
||||
if [ -t 0 ]; then is_tty=true; fi
|
||||
|
||||
if $is_tty; then
|
||||
# Save current stty state; restore on any exit path.
|
||||
local saved_stty
|
||||
saved_stty=$(stty -g 2>/dev/null || echo "")
|
||||
# shellcheck disable=SC2064
|
||||
trap "stty '$saved_stty' 2>/dev/null; printf '\n' >&2" INT TERM EXIT
|
||||
stty -echo 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Prompt on stderr so the caller can capture stdout cleanly.
|
||||
printf '%s' "$prompt" >&2
|
||||
|
||||
# Read one line from stdin. `read -r` returns nonzero on EOF-without-
|
||||
# newline but still populates `value` with whatever it saw — we want that
|
||||
# content, so don't clear on failure.
|
||||
local value=""
|
||||
IFS= read -r value || true
|
||||
|
||||
if $is_tty; then
|
||||
stty "$saved_stty" 2>/dev/null || true
|
||||
trap - INT TERM EXIT
|
||||
printf '\n' >&2
|
||||
fi
|
||||
|
||||
# Assign + export to the named variable.
|
||||
printf -v "$varname" '%s' "$value"
|
||||
# shellcheck disable=SC2163
|
||||
export "$varname"
|
||||
|
||||
# Optional redacted preview after successful read.
|
||||
if [ -n "$redact_expr" ] && [ -n "$value" ]; then
|
||||
local preview
|
||||
preview=$(printf '%s' "$value" | sed "$redact_expr" 2>/dev/null || true)
|
||||
if [ -n "$preview" ]; then
|
||||
printf 'Got: %s\n' "$preview" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-repo-policy — per-remote trust tier for gbrain repo ingest.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-gbrain-repo-policy get [<remote-url>]
|
||||
# Print the tier for the given remote, or the current repo's origin
|
||||
# if no URL is passed. Exits 0 with one of: read-write, read-only,
|
||||
# deny, unset.
|
||||
#
|
||||
# gstack-gbrain-repo-policy set <remote-url> <read-write|read-only|deny>
|
||||
# Persist a tier for the given remote. Exits 0 on success.
|
||||
#
|
||||
# gstack-gbrain-repo-policy list
|
||||
# Print every entry as "<key>\t<tier>", sorted by key.
|
||||
#
|
||||
# gstack-gbrain-repo-policy normalize <url>
|
||||
# Print the normalized (canonical) key for a given remote URL.
|
||||
# Use this when other skills or tests need the same collapsing logic.
|
||||
#
|
||||
# gstack-gbrain-repo-policy --help
|
||||
#
|
||||
# Storage:
|
||||
# ~/.gstack/gbrain-repo-policy.json, mode 0600.
|
||||
#
|
||||
# File format:
|
||||
# {
|
||||
# "_schema_version": 2,
|
||||
# "github.com/foo/bar": "read-write",
|
||||
# "github.com/baz/qux": "deny"
|
||||
# }
|
||||
#
|
||||
# Tier semantics:
|
||||
# read-write — agent may search AND write new pages from this repo.
|
||||
# read-only — agent may search but NEVER write pages from this repo.
|
||||
# (Enforced at the caller level; this binary just stores the
|
||||
# decision.)
|
||||
# deny — no gbrain interaction at all.
|
||||
#
|
||||
# Legacy migration:
|
||||
# On any read of a file missing `_schema_version` (or with version < 2),
|
||||
# legacy `allow` values are atomically rewritten to `read-write`, and
|
||||
# `_schema_version: 2` is added. Log line emitted on stderr when the
|
||||
# migration actually changes anything. Idempotent: running twice is safe.
|
||||
#
|
||||
# Env:
|
||||
# GSTACK_HOME — override ~/.gstack state directory (aligns with other
|
||||
# gstack-* bins; used heavily in tests).
|
||||
set -euo pipefail
|
||||
|
||||
STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
POLICY_FILE="$STATE_DIR/gbrain-repo-policy.json"
|
||||
SCHEMA_VERSION=2
|
||||
|
||||
die() { echo "gstack-gbrain-repo-policy: $*" >&2; exit 2; }
|
||||
|
||||
require_jq() {
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
die "jq is required. Install with: brew install jq"
|
||||
fi
|
||||
}
|
||||
|
||||
# normalize <url> — canonical form: lowercase host + path, no protocol,
|
||||
# no userinfo, no trailing .git or /. SSH shorthand (git@host:path) collapses
|
||||
# to the same key as https://host/path.
|
||||
normalize() {
|
||||
local url="$1"
|
||||
[ -z "$url" ] && { echo ""; return 0; }
|
||||
# Strip protocol://
|
||||
url="${url#*://}"
|
||||
# Strip userinfo (git@, user:password@, etc.) — everything up to and
|
||||
# including the first @ iff an @ appears before the first / or :.
|
||||
case "$url" in
|
||||
*@*)
|
||||
local before_at="${url%%@*}"
|
||||
case "$before_at" in
|
||||
*/*|*:*) : ;; # @ is in the path, not userinfo — leave it
|
||||
*) url="${url#*@}" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
# SSH shorthand: github.com:foo/bar → github.com/foo/bar. Only when the
|
||||
# hostname-part (before first /) contains a colon. sed is clearer than
|
||||
# bash's `${var/:/\/}` which has tricky escaping.
|
||||
local head="${url%%/*}"
|
||||
case "$head" in
|
||||
*:*) url=$(printf '%s' "$url" | sed 's|:|/|') ;;
|
||||
esac
|
||||
# Strip trailing .git
|
||||
url="${url%.git}"
|
||||
# Strip trailing /
|
||||
url="${url%/}"
|
||||
# Lowercase the whole thing. GitHub and most hosts are case-insensitive on
|
||||
# paths anyway; collapsing avoids duplicate entries for "Foo/Bar" vs
|
||||
# "foo/bar".
|
||||
printf '%s\n' "$url" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
# ensure_file — create the policy file if missing, migrate if legacy.
|
||||
# Emits the migration log line on stderr exactly once per run when a
|
||||
# migration actually rewrites values.
|
||||
ensure_file() {
|
||||
require_jq
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
if [ ! -f "$POLICY_FILE" ]; then
|
||||
# Fresh file — just the schema version, no entries.
|
||||
local tmp
|
||||
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
|
||||
printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp"
|
||||
mv "$tmp" "$POLICY_FILE"
|
||||
chmod 0600 "$POLICY_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# File exists — validate, migrate if needed.
|
||||
local raw
|
||||
if ! raw=$(cat "$POLICY_FILE" 2>/dev/null); then
|
||||
die "Cannot read $POLICY_FILE"
|
||||
fi
|
||||
|
||||
# Corrupt JSON → quarantine and start fresh.
|
||||
if ! echo "$raw" | jq empty 2>/dev/null; then
|
||||
local ts
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
local quarantine="$POLICY_FILE.corrupt-$ts"
|
||||
mv "$POLICY_FILE" "$quarantine"
|
||||
echo "gstack-gbrain-repo-policy: corrupt policy file quarantined to $quarantine; starting fresh" >&2
|
||||
local tmp
|
||||
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
|
||||
printf '{"_schema_version":%d}\n' "$SCHEMA_VERSION" > "$tmp"
|
||||
mv "$tmp" "$POLICY_FILE"
|
||||
chmod 0600 "$POLICY_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check schema version.
|
||||
local version
|
||||
version=$(echo "$raw" | jq -r '._schema_version // 0')
|
||||
if [ "$version" -ge "$SCHEMA_VERSION" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Migrate: rename `allow` → `read-write`, add _schema_version.
|
||||
local allow_count migrated
|
||||
allow_count=$(echo "$raw" | jq '[to_entries[] | select(.key != "_schema_version" and .value == "allow")] | length')
|
||||
migrated=$(echo "$raw" | jq --argjson v "$SCHEMA_VERSION" '
|
||||
(to_entries | map(
|
||||
if .key == "_schema_version" then empty
|
||||
elif .value == "allow" then .value = "read-write"
|
||||
else .
|
||||
end
|
||||
) | from_entries) + {_schema_version: $v}
|
||||
')
|
||||
local tmp
|
||||
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
|
||||
printf '%s\n' "$migrated" > "$tmp"
|
||||
mv "$tmp" "$POLICY_FILE"
|
||||
chmod 0600 "$POLICY_FILE"
|
||||
if [ "$allow_count" -gt 0 ]; then
|
||||
echo "[gstack-gbrain-repo-policy] Migrated $allow_count legacy allow entries to read-write" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_get() {
|
||||
local url="${1:-}"
|
||||
if [ -z "$url" ]; then
|
||||
url=$(git remote get-url origin 2>/dev/null || true)
|
||||
if [ -z "$url" ]; then
|
||||
echo "unset"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local key
|
||||
key=$(normalize "$url")
|
||||
if [ -z "$key" ]; then
|
||||
echo "unset"
|
||||
return 0
|
||||
fi
|
||||
ensure_file
|
||||
jq -r --arg key "$key" '.[$key] // "unset"' "$POLICY_FILE"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local url="${1:-}"
|
||||
local tier="${2:-}"
|
||||
[ -z "$url" ] && die "usage: set <remote-url> <tier>"
|
||||
[ -z "$tier" ] && die "usage: set <remote-url> <tier>"
|
||||
case "$tier" in
|
||||
read-write|read-only|deny) ;;
|
||||
*) die "invalid tier '$tier' (must be one of: read-write, read-only, deny)" ;;
|
||||
esac
|
||||
local key
|
||||
key=$(normalize "$url")
|
||||
[ -z "$key" ] && die "cannot normalize remote URL: $url"
|
||||
ensure_file
|
||||
local tmp
|
||||
tmp=$(mktemp "$POLICY_FILE.tmp.XXXXXX")
|
||||
jq --arg key "$key" --arg tier "$tier" '.[$key] = $tier' "$POLICY_FILE" > "$tmp"
|
||||
mv "$tmp" "$POLICY_FILE"
|
||||
chmod 0600 "$POLICY_FILE"
|
||||
echo "Set $key → $tier"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
if [ ! -f "$POLICY_FILE" ]; then
|
||||
# Nothing to list; don't create the file just for a read.
|
||||
return 0
|
||||
fi
|
||||
ensure_file
|
||||
jq -r 'to_entries[] | select(.key != "_schema_version") | "\(.key)\t\(.value)"' "$POLICY_FILE" | sort
|
||||
}
|
||||
|
||||
cmd_normalize() {
|
||||
local url="${1:-}"
|
||||
[ -z "$url" ] && die "usage: normalize <url>"
|
||||
normalize "$url"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
get) shift; cmd_get "$@" ;;
|
||||
set) shift; cmd_set "$@" ;;
|
||||
list) shift; cmd_list "$@" ;;
|
||||
normalize) shift; cmd_normalize "$@" ;;
|
||||
--help|-h|help) sed -n '2,47p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
"") die "usage: gstack-gbrain-repo-policy {get|set|list|normalize|--help}" ;;
|
||||
*) die "unknown subcommand: $1" ;;
|
||||
esac
|
||||
Executable
+447
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-supabase-provision — Supabase Management API wrapper for
|
||||
# /setup-gbrain path 2a (auto-provision).
|
||||
#
|
||||
# Subcommands:
|
||||
# list-orgs
|
||||
# GET /v1/organizations. Output: {"orgs": [{"slug","name"}, ...]}
|
||||
#
|
||||
# create <name> <region> <org-slug>
|
||||
# POST /v1/projects with {name, db_pass, organization_slug, region}.
|
||||
# db_pass must be in the DB_PASS env var (never argv — D8 grep test
|
||||
# enforces this). Output: {"ref","name","region","organization_slug","status"}.
|
||||
#
|
||||
# NOTE: does NOT send a `plan` field. Per verified Supabase Management
|
||||
# API OpenAPI, the `plan` field is now deprecated at the project level
|
||||
# — subscription tier is an org-level decision (D17 updated).
|
||||
#
|
||||
# wait <ref> [--timeout <seconds>]
|
||||
# Poll GET /v1/projects/{ref} every 5s until status=ACTIVE_HEALTHY,
|
||||
# or fail on terminal states (INIT_FAILED, REMOVED). Default timeout
|
||||
# 180s. Output on success: {"ref","status","elapsed_s"}.
|
||||
#
|
||||
# pooler-url <ref>
|
||||
# GET /v1/projects/{ref}/config/database/pooler, construct the full
|
||||
# Session Pooler URL using DB_PASS from env (the API response's
|
||||
# connection_string is typically templated [PASSWORD] rather than the
|
||||
# real value — we build from db_user/db_host/db_port/db_name instead).
|
||||
# Output: {"ref","pooler_url"}.
|
||||
#
|
||||
# list-orphans [--name-prefix <str>]
|
||||
# GET /v1/projects. Filter to projects whose name starts with --name-prefix
|
||||
# (default "gbrain") AND whose ref does NOT match the one in the local
|
||||
# active ~/.gbrain/config.json pooler URL. Those are the gbrain-shaped
|
||||
# projects that aren't pointed at by a working local config — candidates
|
||||
# for /setup-gbrain --cleanup-orphans.
|
||||
# Output: {"active_ref","orphans":[{"ref","name","created_at","region"}, ...]}.
|
||||
#
|
||||
# delete-project <ref>
|
||||
# DELETE /v1/projects/{ref}. Destructive, one-way — callers must
|
||||
# double-confirm before invoking. This bin performs NO confirmation
|
||||
# prompt; the skill's UI layer owns that responsibility.
|
||||
# Output: {"deleted_ref"}.
|
||||
#
|
||||
# Secrets discipline (D8, D10, D11):
|
||||
# - SUPABASE_ACCESS_TOKEN is read from env; never accepted as argv.
|
||||
# - DB_PASS (for `create` and `pooler-url`) is read from env; never argv.
|
||||
# - Forbidden strings (enforced by skill-validation grep test):
|
||||
# --insecure, -k (curl), NODE_TLS_REJECT_UNAUTHORIZED
|
||||
# - `set +x` default — debug mode requires explicit opt-in around
|
||||
# non-secret lines.
|
||||
#
|
||||
# Env:
|
||||
# SUPABASE_ACCESS_TOKEN — PAT for auth (required on all subcommands)
|
||||
# DB_PASS — database password (required for create + pooler-url)
|
||||
# SUPABASE_API_BASE — override the API host (tests point this at a
|
||||
# local mock server). Default: https://api.supabase.com
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — success
|
||||
# 2 — usage / invalid input
|
||||
# 3 — auth failure (401/403) — retry with fresh PAT
|
||||
# 4 — quota / billing (402) — user action needed
|
||||
# 5 — conflict (409) — duplicate name, user action needed
|
||||
# 6 — timeout (wait subcommand hit its deadline)
|
||||
# 7 — terminal failure state from Supabase (INIT_FAILED, REMOVED)
|
||||
# 8 — network / 5xx after retries
|
||||
set +x # Defensive: never trace secrets in this helper.
|
||||
set -euo pipefail
|
||||
|
||||
SUPABASE_API_BASE="${SUPABASE_API_BASE:-https://api.supabase.com}"
|
||||
API_VERSION="v1"
|
||||
DEFAULT_WAIT_TIMEOUT=180
|
||||
POLL_INTERVAL=5
|
||||
CURL_TIMEOUT=30
|
||||
|
||||
die() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 2; }
|
||||
die_auth() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 3; }
|
||||
die_quota(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 4; }
|
||||
die_conflict(){ echo "gstack-gbrain-supabase-provision: $*" >&2; exit 5; }
|
||||
die_net() { echo "gstack-gbrain-supabase-provision: $*" >&2; exit 8; }
|
||||
|
||||
require_jq() {
|
||||
command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq"
|
||||
}
|
||||
require_curl() {
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required"
|
||||
}
|
||||
|
||||
require_pat() {
|
||||
if [ -z "${SUPABASE_ACCESS_TOKEN:-}" ]; then
|
||||
die_auth "SUPABASE_ACCESS_TOKEN is not set. Generate a PAT at https://supabase.com/dashboard/account/tokens"
|
||||
fi
|
||||
}
|
||||
|
||||
require_db_pass() {
|
||||
if [ -z "${DB_PASS:-}" ]; then
|
||||
die "DB_PASS env var is required (never passed as argv — that leaks via ps/history)"
|
||||
fi
|
||||
}
|
||||
|
||||
# api_call <method> <path> [<json-body-file>]
|
||||
# Handles: 401/403 → exit 3, 402 → 4, 409 → 5, 429 + 5xx → retry w/
|
||||
# exponential backoff up to 3 attempts. Returns the response body on
|
||||
# stdout and HTTP status on an internal variable via a pipe trick.
|
||||
#
|
||||
# Because bash lacks multi-value returns, we write response body to a
|
||||
# tmpfile + status to another tmpfile and the caller reads them.
|
||||
api_call() {
|
||||
local method="$1"
|
||||
local apipath="$2"
|
||||
local body_file="${3:-}"
|
||||
|
||||
local url="$SUPABASE_API_BASE/$API_VERSION/$apipath"
|
||||
local body_tmp
|
||||
body_tmp=$(mktemp)
|
||||
local status_tmp
|
||||
status_tmp=$(mktemp)
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -f '$body_tmp' '$status_tmp'" RETURN
|
||||
|
||||
local attempt=0
|
||||
local max_attempts=3
|
||||
local backoff=2
|
||||
while : ; do
|
||||
attempt=$((attempt + 1))
|
||||
local curl_args=(
|
||||
--silent
|
||||
--show-error
|
||||
--max-time "$CURL_TIMEOUT"
|
||||
-o "$body_tmp"
|
||||
-w "%{http_code}"
|
||||
-X "$method"
|
||||
-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"
|
||||
-H "Accept: application/json"
|
||||
-H "Content-Type: application/json"
|
||||
-H "User-Agent: gstack-gbrain-supabase-provision"
|
||||
)
|
||||
if [ -n "$body_file" ]; then
|
||||
curl_args+=(--data-binary "@$body_file")
|
||||
fi
|
||||
local status
|
||||
if ! status=$(curl "${curl_args[@]}" "$url" 2>/dev/null); then
|
||||
# curl itself failed (network, timeout, etc.). Retry.
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
die_net "network failure calling $method $apipath after $attempt attempts"
|
||||
fi
|
||||
sleep "$backoff"
|
||||
backoff=$((backoff * 2))
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$status" in
|
||||
2??)
|
||||
cat "$body_tmp"
|
||||
printf '%s' "$status" > "$status_tmp"
|
||||
return 0
|
||||
;;
|
||||
401)
|
||||
die_auth "401 Unauthorized — your PAT is invalid or expired. Re-generate at https://supabase.com/dashboard/account/tokens"
|
||||
;;
|
||||
403)
|
||||
die_auth "403 Forbidden — your PAT lacks permission for $method $apipath. Regenerate with All Access scope."
|
||||
;;
|
||||
402)
|
||||
die_quota "402 Payment Required — Supabase project/organization quota exceeded. See https://supabase.com/dashboard"
|
||||
;;
|
||||
409)
|
||||
die_conflict "409 Conflict on $method $apipath — likely a duplicate project name. Pick a different name and re-run."
|
||||
;;
|
||||
429|5??)
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
die_net "$status after $attempt attempts on $method $apipath"
|
||||
fi
|
||||
sleep "$backoff"
|
||||
backoff=$((backoff * 2))
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
# 400, 404, etc. — surface the error body for debugging.
|
||||
local err
|
||||
err=$(jq -r '.message // .error // empty' "$body_tmp" 2>/dev/null || true)
|
||||
if [ -n "$err" ]; then
|
||||
die "HTTP $status from $method $apipath: $err"
|
||||
else
|
||||
die "HTTP $status from $method $apipath (no error message in response)"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd_list_orgs() {
|
||||
local json_mode=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--json) json_mode=true; shift ;;
|
||||
*) die "list-orgs: unknown flag: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_jq; require_curl; require_pat
|
||||
local resp
|
||||
resp=$(api_call GET organizations)
|
||||
if $json_mode; then
|
||||
printf '%s' "$resp" | jq '{orgs: map({slug: .slug, name: .name})}'
|
||||
else
|
||||
printf '%s' "$resp" | jq -r '.[] | "\(.slug)\t\(.name)"'
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_create() {
|
||||
local name="" region="" org_slug=""
|
||||
local json_mode=false
|
||||
local instance_size=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--json) json_mode=true; shift ;;
|
||||
--instance-size) instance_size="$2"; shift 2 ;;
|
||||
--*) die "create: unknown flag: $1" ;;
|
||||
*)
|
||||
if [ -z "$name" ]; then name="$1"
|
||||
elif [ -z "$region" ]; then region="$1"
|
||||
elif [ -z "$org_slug" ]; then org_slug="$1"
|
||||
else die "create: too many positional arguments"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ -z "$name" ] && die "create: missing <name>"
|
||||
[ -z "$region" ] && die "create: missing <region>"
|
||||
[ -z "$org_slug" ] && die "create: missing <org-slug>"
|
||||
|
||||
require_jq; require_curl; require_pat; require_db_pass
|
||||
|
||||
local body_file
|
||||
body_file=$(mktemp)
|
||||
# shellcheck disable=SC2064
|
||||
trap "rm -f '$body_file'" RETURN
|
||||
if [ -n "$instance_size" ]; then
|
||||
jq -n \
|
||||
--arg name "$name" \
|
||||
--arg db_pass "$DB_PASS" \
|
||||
--arg organization_slug "$org_slug" \
|
||||
--arg region "$region" \
|
||||
--arg desired_instance_size "$instance_size" \
|
||||
'{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region, desired_instance_size: $desired_instance_size}' \
|
||||
> "$body_file"
|
||||
else
|
||||
jq -n \
|
||||
--arg name "$name" \
|
||||
--arg db_pass "$DB_PASS" \
|
||||
--arg organization_slug "$org_slug" \
|
||||
--arg region "$region" \
|
||||
'{name: $name, db_pass: $db_pass, organization_slug: $organization_slug, region: $region}' \
|
||||
> "$body_file"
|
||||
fi
|
||||
|
||||
local resp
|
||||
resp=$(api_call POST projects "$body_file")
|
||||
if $json_mode; then
|
||||
printf '%s' "$resp" | jq '{ref, name, region, organization_slug, status}'
|
||||
else
|
||||
printf '%s' "$resp" | jq -r '"ref=\(.ref) status=\(.status) region=\(.region)"'
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_wait() {
|
||||
local ref="" timeout="$DEFAULT_WAIT_TIMEOUT"
|
||||
local json_mode=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--timeout) timeout="$2"; shift 2 ;;
|
||||
--json) json_mode=true; shift ;;
|
||||
--*) die "wait: unknown flag: $1" ;;
|
||||
*) ref="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && die "wait: missing <ref>"
|
||||
|
||||
require_jq; require_curl; require_pat
|
||||
|
||||
local elapsed=0
|
||||
while : ; do
|
||||
local resp
|
||||
resp=$(api_call GET "projects/$ref")
|
||||
local status
|
||||
status=$(printf '%s' "$resp" | jq -r '.status // "UNKNOWN"')
|
||||
case "$status" in
|
||||
ACTIVE_HEALTHY)
|
||||
if $json_mode; then
|
||||
jq -n --arg ref "$ref" --arg status "$status" --argjson elapsed "$elapsed" \
|
||||
'{ref: $ref, status: $status, elapsed_s: $elapsed}'
|
||||
else
|
||||
echo "ready ref=$ref status=$status elapsed_s=$elapsed"
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
INIT_FAILED|REMOVED|RESTORE_FAILED|PAUSE_FAILED)
|
||||
echo "gstack-gbrain-supabase-provision: project $ref reached terminal failure state '$status'" >&2
|
||||
exit 7
|
||||
;;
|
||||
COMING_UP|INACTIVE|ACTIVE_UNHEALTHY|UNKNOWN|RESTORING|UPGRADING|PAUSING|RESTARTING|RESIZING|GOING_DOWN)
|
||||
# Still provisioning — keep polling.
|
||||
;;
|
||||
*)
|
||||
# Unexpected status from Supabase. Log but keep polling.
|
||||
echo "gstack-gbrain-supabase-provision: unexpected status '$status' — continuing to poll" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "gstack-gbrain-supabase-provision: wait timed out after ${timeout}s (last status: $status)" >&2
|
||||
echo "gstack-gbrain-supabase-provision: re-run with /setup-gbrain --resume-provision $ref" >&2
|
||||
exit 6
|
||||
fi
|
||||
sleep "$POLL_INTERVAL"
|
||||
elapsed=$((elapsed + POLL_INTERVAL))
|
||||
done
|
||||
}
|
||||
|
||||
cmd_pooler_url() {
|
||||
local ref=""
|
||||
local json_mode=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--json) json_mode=true; shift ;;
|
||||
--*) die "pooler-url: unknown flag: $1" ;;
|
||||
*) ref="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && die "pooler-url: missing <ref>"
|
||||
|
||||
require_jq; require_curl; require_pat; require_db_pass
|
||||
|
||||
local resp
|
||||
resp=$(api_call GET "projects/$ref/config/database/pooler")
|
||||
|
||||
# Prefer the singular Session Pooler config when Supabase returns an
|
||||
# array (response shape can vary by project state). Fall back to the
|
||||
# first PRIMARY entry if no "session" pool_mode is present.
|
||||
local db_user db_host db_port db_name
|
||||
local first_or_session
|
||||
if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
||||
first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]')
|
||||
else
|
||||
first_or_session="$resp"
|
||||
fi
|
||||
|
||||
db_user=$(printf '%s' "$first_or_session" | jq -r '.db_user // empty')
|
||||
db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty')
|
||||
db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty')
|
||||
db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty')
|
||||
|
||||
if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then
|
||||
die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state"
|
||||
fi
|
||||
|
||||
local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}"
|
||||
|
||||
if $json_mode; then
|
||||
jq -n --arg ref "$ref" --arg pooler_url "$url" '{ref: $ref, pooler_url: $pooler_url}'
|
||||
else
|
||||
# Non-JSON mode prints the URL; callers capturing it into a variable
|
||||
# keep it in process memory only.
|
||||
echo "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_list_orphans() {
|
||||
local name_prefix="gbrain"
|
||||
local json_mode=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name-prefix) name_prefix="$2"; shift 2 ;;
|
||||
--json) json_mode=true; shift ;;
|
||||
--*) die "list-orphans: unknown flag: $1" ;;
|
||||
*) die "list-orphans: unexpected arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_jq; require_curl; require_pat
|
||||
local all
|
||||
all=$(api_call GET projects)
|
||||
|
||||
# Extract the active brain's ref from ~/.gbrain/config.json if present.
|
||||
# Pooler URL format: postgresql://postgres.<ref>:<pw>@...
|
||||
local active_ref="null"
|
||||
local gbrain_cfg="$HOME/.gbrain/config.json"
|
||||
if [ -f "$gbrain_cfg" ]; then
|
||||
local url
|
||||
url=$(jq -r '.database_url // empty' "$gbrain_cfg" 2>/dev/null || true)
|
||||
if [ -n "$url" ]; then
|
||||
# Extract user portion before the colon: postgresql://USER:pw@...
|
||||
local user
|
||||
user=$(printf '%s' "$url" | sed -E 's|^[a-z]+://([^:]+):.*$|\1|')
|
||||
# User format: postgres.<ref> — pull ref suffix
|
||||
case "$user" in
|
||||
postgres.*)
|
||||
local ref="${user#postgres.}"
|
||||
active_ref=$(jq -Rn --arg r "$ref" '$r')
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
local orphans
|
||||
orphans=$(printf '%s' "$all" | jq \
|
||||
--arg prefix "$name_prefix" \
|
||||
--argjson active "$active_ref" \
|
||||
'[.[]
|
||||
| select(.name | startswith($prefix))
|
||||
| select(.ref != $active)
|
||||
| {ref: .ref, name: .name, created_at: .created_at, region: .region}]')
|
||||
|
||||
jq -n --argjson active "$active_ref" --argjson orphans "$orphans" \
|
||||
'{active_ref: $active, orphans: $orphans}'
|
||||
}
|
||||
|
||||
cmd_delete_project() {
|
||||
local ref=""
|
||||
local json_mode=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--json) json_mode=true; shift ;;
|
||||
--*) die "delete-project: unknown flag: $1" ;;
|
||||
*) ref="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$ref" ] && die "delete-project: missing <ref>"
|
||||
|
||||
require_jq; require_curl; require_pat
|
||||
api_call DELETE "projects/$ref" >/dev/null
|
||||
jq -n --arg ref "$ref" '{deleted_ref: $ref}'
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
list-orgs) shift; cmd_list_orgs "$@" ;;
|
||||
create) shift; cmd_create "$@" ;;
|
||||
wait) shift; cmd_wait "$@" ;;
|
||||
pooler-url) shift; cmd_pooler_url "$@" ;;
|
||||
list-orphans) shift; cmd_list_orphans "$@" ;;
|
||||
delete-project) shift; cmd_delete_project "$@" ;;
|
||||
--help|-h|help) sed -n '2,80p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
"") die "usage: gstack-gbrain-supabase-provision {list-orgs|create|wait|pooler-url|list-orphans|delete-project|--help}" ;;
|
||||
*) die "unknown subcommand: $1" ;;
|
||||
esac
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-supabase-verify — structural check on a Supabase Session
|
||||
# Pooler URL before handing it to `gbrain init`.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-gbrain-supabase-verify <url>
|
||||
# echo "<url>" | gstack-gbrain-supabase-verify -
|
||||
#
|
||||
# Accepts ONLY Session Pooler URLs (port 6543, host *.pooler.supabase.com).
|
||||
# Rejects direct-connection URLs (db.*.supabase.co:5432) since those are
|
||||
# IPv6-only and fail in many environments — gbrain's init wizard warns
|
||||
# about this at init.ts:150-158.
|
||||
#
|
||||
# Canonical shape (per gbrain init.ts:266):
|
||||
# postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — URL passes structural check
|
||||
# 2 — invalid format (bad scheme, port, host, userinfo, or empty password)
|
||||
# 3 — direct-connection URL rejected (common mistake, special-cased for UX)
|
||||
#
|
||||
# The verifier never makes a network call; purely a regex match. Whether
|
||||
# the URL actually works (database up, password correct, host reachable)
|
||||
# is gbrain's problem at init time.
|
||||
#
|
||||
# Reads URL from:
|
||||
# 1. argv[1] if provided and not "-"
|
||||
# 2. stdin if argv[1] is "-" or missing
|
||||
#
|
||||
# Never echoes the URL to stderr (it contains a password). Error messages
|
||||
# refer to "the URL" generically.
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "gstack-gbrain-supabase-verify: $*" >&2; exit 2; }
|
||||
reject_direct() {
|
||||
cat >&2 <<EOF
|
||||
gstack-gbrain-supabase-verify: rejected direct-connection URL
|
||||
|
||||
You pasted a Supabase direct-connection URL (db.*.supabase.co on port
|
||||
5432). Direct connections are IPv6-only and fail in many environments.
|
||||
|
||||
Use the Session Pooler instead:
|
||||
Supabase Dashboard → Settings → Database → Connection Pooler →
|
||||
Transaction/Session → copy URI (port 6543)
|
||||
|
||||
Expected shape:
|
||||
postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres
|
||||
EOF
|
||||
exit 3
|
||||
}
|
||||
|
||||
URL=""
|
||||
case "${1:-}" in
|
||||
-) URL=$(cat) ;;
|
||||
"") URL=$(cat) ;;
|
||||
*) URL="$1" ;;
|
||||
esac
|
||||
|
||||
URL=$(printf '%s' "$URL" | tr -d '[:space:]')
|
||||
[ -z "$URL" ] && die "empty URL"
|
||||
|
||||
# Scheme: must be postgresql:// or postgres://. Explicitly reject other
|
||||
# schemes rather than guess.
|
||||
case "$URL" in
|
||||
postgresql://*|postgres://*) ;;
|
||||
*) die "bad scheme (must start with postgresql:// or postgres://)" ;;
|
||||
esac
|
||||
|
||||
# Strip scheme to expose userinfo + host + port + path.
|
||||
rest="${URL#*://}"
|
||||
|
||||
# Userinfo portion: everything before the first @. Must contain a : (user:pass).
|
||||
case "$rest" in
|
||||
*@*) ;;
|
||||
*) die "missing userinfo (expected postgres.<ref>:<password>@host)" ;;
|
||||
esac
|
||||
userinfo="${rest%%@*}"
|
||||
after_at="${rest#*@}"
|
||||
|
||||
# Userinfo must be user:password with neither part empty.
|
||||
case "$userinfo" in
|
||||
*:*) ;;
|
||||
*) die "userinfo missing password separator (expected user:password@)" ;;
|
||||
esac
|
||||
user_part="${userinfo%%:*}"
|
||||
pass_part="${userinfo#*:}"
|
||||
[ -z "$user_part" ] && die "empty user portion in userinfo"
|
||||
[ -z "$pass_part" ] && die "empty password in userinfo"
|
||||
|
||||
# Host + port + path.
|
||||
# Direct-connection detection FIRST (specific error beats generic).
|
||||
case "$after_at" in
|
||||
db.*.supabase.co:5432*|db.*.supabase.co/*|db.*.supabase.co) reject_direct ;;
|
||||
esac
|
||||
|
||||
# Extract host:port (before first / if present).
|
||||
hostport="${after_at%%/*}"
|
||||
case "$hostport" in
|
||||
*:*) ;;
|
||||
*) die "missing port (Session Pooler requires :6543)" ;;
|
||||
esac
|
||||
host="${hostport%:*}"
|
||||
port="${hostport##*:}"
|
||||
|
||||
# Host must be *.pooler.supabase.com (case-insensitive).
|
||||
host_lower=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
||||
case "$host_lower" in
|
||||
*.pooler.supabase.com) ;;
|
||||
*) die "host '$host' is not a Supabase Session Pooler (expected *.pooler.supabase.com)" ;;
|
||||
esac
|
||||
|
||||
# Port must be 6543 (Session Pooler default).
|
||||
if [ "$port" != "6543" ]; then
|
||||
die "port must be 6543 for Session Pooler (got $port)"
|
||||
fi
|
||||
|
||||
# User portion should look like postgres.<ref> (20-char lowercase ref,
|
||||
# per the Supabase Management API contract). Not strictly required by
|
||||
# gbrain, but rejecting a plain "postgres" user catches a common paste
|
||||
# error where someone grabs the Direct URL userinfo by mistake.
|
||||
case "$user_part" in
|
||||
postgres.*) ;;
|
||||
*) die "user portion '$user_part' should be 'postgres.<project-ref>' (20-char ref)" ;;
|
||||
esac
|
||||
|
||||
echo "ok"
|
||||
+46
-17
@@ -1067,6 +1067,12 @@ command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip"
|
||||
|
||||
# Shell linting
|
||||
command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck"
|
||||
|
||||
# GBrain presence (D6) — only report as a dimension if gbrain is actually
|
||||
# set up; otherwise skip so machines without gbrain aren't penalized.
|
||||
if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then
|
||||
echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)"
|
||||
fi
|
||||
```
|
||||
|
||||
Use Glob to search for shell scripts:
|
||||
@@ -1131,11 +1137,12 @@ Score each category on a 0-10 scale using this rubric:
|
||||
|
||||
| Category | Weight | 10 | 7 | 4 | 0 |
|
||||
|-----------|--------|------|-----------|------------|-----------|
|
||||
| Type check | 25% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors |
|
||||
| Lint | 20% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings |
|
||||
| Tests | 30% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
|
||||
| Dead code | 15% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
|
||||
| Shell lint | 10% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
|
||||
| Type check | 22% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors |
|
||||
| Lint | 18% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings |
|
||||
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
|
||||
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
|
||||
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
|
||||
| GBrain (D6) | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
|
||||
|
||||
**Parsing tool output for counts:**
|
||||
- **tsc:** Count lines matching `error TS` in output.
|
||||
@@ -1146,11 +1153,30 @@ Score each category on a 0-10 scale using this rubric:
|
||||
|
||||
**Composite score:**
|
||||
```
|
||||
composite = (typecheck_score * 0.25) + (lint_score * 0.20) + (test_score * 0.30) + (deadcode_score * 0.15) + (shell_score * 0.10)
|
||||
composite = (typecheck_score * 0.22) + (lint_score * 0.18) + (test_score * 0.28) + (deadcode_score * 0.13) + (shell_score * 0.09) + (gbrain_score * 0.10)
|
||||
```
|
||||
|
||||
If a category is skipped (tool not available), redistribute its weight proportionally
|
||||
among the remaining categories.
|
||||
If a category is skipped (tool not available — includes GBrain when gbrain
|
||||
is not installed), redistribute its weight proportionally among the
|
||||
remaining categories.
|
||||
|
||||
**GBrain sub-score computation (D6):**
|
||||
|
||||
```
|
||||
doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
|
||||
7 if "warnings"; 0 otherwise (or command times out after 5s).
|
||||
queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
|
||||
7 if 10-100; 0 if >=100 (suggests secret-scan rejections
|
||||
piling up). N/A if gbrain_sync_mode == off.
|
||||
push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
|
||||
7 if <72h; 0 if >=72h. N/A if gbrain_sync_mode == off.
|
||||
gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
|
||||
(redistribute 0.3 + 0.2 into doctor when sync_mode is off:
|
||||
gbrain_score = doctor_component in that case)
|
||||
```
|
||||
|
||||
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
|
||||
or misconfigured gbrain doesn't stall the entire /health dashboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -1173,6 +1199,7 @@ Lint biome check . 8/10 WARNING 2s 3 warnings
|
||||
Tests bun test 10/10 CLEAN 12s 47/47 passed
|
||||
Dead code knip 7/10 WARNING 5s 4 unused exports
|
||||
Shell lint shellcheck 10/10 CLEAN 1s 0 issues
|
||||
GBrain gbrain doctor 10/10 CLEAN <1s doctor=ok, queue=3, pushed 2h ago
|
||||
|
||||
COMPOSITE SCORE: 9.1 / 10
|
||||
|
||||
@@ -1206,17 +1233,19 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gst
|
||||
Append one JSONL line to `~/.gstack/projects/$SLUG/health-history.jsonl`:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"duration_s":23}
|
||||
{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"gbrain":10,"duration_s":23}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `ts` -- ISO 8601 timestamp
|
||||
- `branch` -- current git branch
|
||||
- `score` -- composite score (one decimal)
|
||||
- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
|
||||
- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
|
||||
- `duration_s` -- total time for all tools in seconds
|
||||
|
||||
If a category was skipped, set its value to `null`.
|
||||
If a category was skipped, set its value to `null`. Pre-D6 history entries
|
||||
won't have a `gbrain` field — treat them as `null` for trend comparison
|
||||
and start new tracking from the first post-D6 run.
|
||||
|
||||
---
|
||||
|
||||
@@ -1235,12 +1264,12 @@ tail -10 ~/.gstack/projects/$SLUG/health-history.jsonl 2>/dev/null || echo "NO_H
|
||||
```
|
||||
HEALTH TREND (last 5 runs)
|
||||
==========================
|
||||
Date Branch Score TC Lint Test Dead Shell
|
||||
---------- ----------- ----- -- ---- ---- ---- -----
|
||||
2026-03-28 main 9.4 10 9 10 8 10
|
||||
2026-03-29 feat/auth 8.8 10 7 10 7 10
|
||||
2026-03-30 feat/auth 8.2 10 6 9 7 10
|
||||
2026-03-31 feat/auth 9.1 10 8 10 7 10
|
||||
Date Branch Score TC Lint Test Dead Shell GBrain
|
||||
---------- ----------- ----- -- ---- ---- ---- ----- ------
|
||||
2026-03-28 main 9.4 10 9 10 8 10 10
|
||||
2026-03-29 feat/auth 8.8 10 7 10 7 10 10
|
||||
2026-03-30 feat/auth 8.2 10 6 9 7 10 7
|
||||
2026-03-31 feat/auth 9.1 10 8 10 7 10 10
|
||||
|
||||
Trend: IMPROVING (+0.9 since last run)
|
||||
```
|
||||
|
||||
+46
-17
@@ -69,6 +69,12 @@ command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip"
|
||||
|
||||
# Shell linting
|
||||
command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck"
|
||||
|
||||
# GBrain presence (D6) — only report as a dimension if gbrain is actually
|
||||
# set up; otherwise skip so machines without gbrain aren't penalized.
|
||||
if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then
|
||||
echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)"
|
||||
fi
|
||||
```
|
||||
|
||||
Use Glob to search for shell scripts:
|
||||
@@ -133,11 +139,12 @@ Score each category on a 0-10 scale using this rubric:
|
||||
|
||||
| Category | Weight | 10 | 7 | 4 | 0 |
|
||||
|-----------|--------|------|-----------|------------|-----------|
|
||||
| Type check | 25% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors |
|
||||
| Lint | 20% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings |
|
||||
| Tests | 30% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
|
||||
| Dead code | 15% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
|
||||
| Shell lint | 10% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
|
||||
| Type check | 22% | Clean (exit 0) | <10 errors | <50 errors | >=50 errors |
|
||||
| Lint | 18% | Clean (exit 0) | <5 warnings | <20 warnings | >=20 warnings |
|
||||
| Tests | 28% | All pass (exit 0) | >95% pass | >80% pass | <=80% pass |
|
||||
| Dead code | 13% | Clean (exit 0) | <5 unused exports | <20 unused | >=20 unused |
|
||||
| Shell lint | 9% | Clean (exit 0) | <5 issues | >=5 issues | N/A (skip) |
|
||||
| GBrain (D6) | 10% | doctor=ok, queue<10, pushed <24h | doctor=warnings OR queue<100 OR pushed <72h | doctor broken OR queue>=100 OR pushed >=72h | N/A (gbrain not installed) |
|
||||
|
||||
**Parsing tool output for counts:**
|
||||
- **tsc:** Count lines matching `error TS` in output.
|
||||
@@ -148,11 +155,30 @@ Score each category on a 0-10 scale using this rubric:
|
||||
|
||||
**Composite score:**
|
||||
```
|
||||
composite = (typecheck_score * 0.25) + (lint_score * 0.20) + (test_score * 0.30) + (deadcode_score * 0.15) + (shell_score * 0.10)
|
||||
composite = (typecheck_score * 0.22) + (lint_score * 0.18) + (test_score * 0.28) + (deadcode_score * 0.13) + (shell_score * 0.09) + (gbrain_score * 0.10)
|
||||
```
|
||||
|
||||
If a category is skipped (tool not available), redistribute its weight proportionally
|
||||
among the remaining categories.
|
||||
If a category is skipped (tool not available — includes GBrain when gbrain
|
||||
is not installed), redistribute its weight proportionally among the
|
||||
remaining categories.
|
||||
|
||||
**GBrain sub-score computation (D6):**
|
||||
|
||||
```
|
||||
doctor_component: 10 if `gbrain doctor --json | jq -r .status` == "ok";
|
||||
7 if "warnings"; 0 otherwise (or command times out after 5s).
|
||||
queue_component: 10 if ~/.gstack/.brain-queue.jsonl has <10 lines;
|
||||
7 if 10-100; 0 if >=100 (suggests secret-scan rejections
|
||||
piling up). N/A if gbrain_sync_mode == off.
|
||||
push_component: 10 if (now - mtime of ~/.gstack/.brain-last-push) < 24h;
|
||||
7 if <72h; 0 if >=72h. N/A if gbrain_sync_mode == off.
|
||||
gbrain_score = 0.5 * doctor_component + 0.3 * queue_component + 0.2 * push_component
|
||||
(redistribute 0.3 + 0.2 into doctor when sync_mode is off:
|
||||
gbrain_score = doctor_component in that case)
|
||||
```
|
||||
|
||||
The `gbrain doctor --json` call MUST be wrapped in `timeout 5s` so a hung
|
||||
or misconfigured gbrain doesn't stall the entire /health dashboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -175,6 +201,7 @@ Lint biome check . 8/10 WARNING 2s 3 warnings
|
||||
Tests bun test 10/10 CLEAN 12s 47/47 passed
|
||||
Dead code knip 7/10 WARNING 5s 4 unused exports
|
||||
Shell lint shellcheck 10/10 CLEAN 1s 0 issues
|
||||
GBrain gbrain doctor 10/10 CLEAN <1s doctor=ok, queue=3, pushed 2h ago
|
||||
|
||||
COMPOSITE SCORE: 9.1 / 10
|
||||
|
||||
@@ -208,17 +235,19 @@ DETAILS: Lint (3 warnings)
|
||||
Append one JSONL line to `~/.gstack/projects/$SLUG/health-history.jsonl`:
|
||||
|
||||
```json
|
||||
{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"duration_s":23}
|
||||
{"ts":"2026-03-31T14:30:00Z","branch":"main","score":9.1,"typecheck":10,"lint":8,"test":10,"deadcode":7,"shell":10,"gbrain":10,"duration_s":23}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `ts` -- ISO 8601 timestamp
|
||||
- `branch` -- current git branch
|
||||
- `score` -- composite score (one decimal)
|
||||
- `typecheck`, `lint`, `test`, `deadcode`, `shell` -- individual category scores (integer 0-10)
|
||||
- `typecheck`, `lint`, `test`, `deadcode`, `shell`, `gbrain` -- individual category scores (integer 0-10)
|
||||
- `duration_s` -- total time for all tools in seconds
|
||||
|
||||
If a category was skipped, set its value to `null`.
|
||||
If a category was skipped, set its value to `null`. Pre-D6 history entries
|
||||
won't have a `gbrain` field — treat them as `null` for trend comparison
|
||||
and start new tracking from the first post-D6 run.
|
||||
|
||||
---
|
||||
|
||||
@@ -237,12 +266,12 @@ tail -10 ~/.gstack/projects/$SLUG/health-history.jsonl 2>/dev/null || echo "NO_H
|
||||
```
|
||||
HEALTH TREND (last 5 runs)
|
||||
==========================
|
||||
Date Branch Score TC Lint Test Dead Shell
|
||||
---------- ----------- ----- -- ---- ---- ---- -----
|
||||
2026-03-28 main 9.4 10 9 10 8 10
|
||||
2026-03-29 feat/auth 8.8 10 7 10 7 10
|
||||
2026-03-30 feat/auth 8.2 10 6 9 7 10
|
||||
2026-03-31 feat/auth 9.1 10 8 10 7 10
|
||||
Date Branch Score TC Lint Test Dead Shell GBrain
|
||||
---------- ----------- ----- -- ---- ---- ---- ----- ------
|
||||
2026-03-28 main 9.4 10 9 10 8 10 10
|
||||
2026-03-29 feat/auth 8.8 10 7 10 7 10 10
|
||||
2026-03-30 feat/auth 8.2 10 6 9 7 10 7
|
||||
2026-03-31 feat/auth 9.1 10 8 10 7 10 10
|
||||
|
||||
Trend: IMPROVING (+0.9 since last run)
|
||||
```
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.11.1.0",
|
||||
"version": "1.12.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: setup-gbrain
|
||||
preamble-tier: 2
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Set up gbrain for this coding agent: install the CLI, initialize a
|
||||
local PGLite or Supabase brain, register MCP, capture per-remote trust
|
||||
policy. One command from zero to "gbrain is running, and this agent
|
||||
can call it." Use when: "setup gbrain", "connect gbrain", "start
|
||||
gbrain", "install gbrain", "configure gbrain for this machine". (gstack)
|
||||
triggers:
|
||||
- setup gbrain
|
||||
- install gbrain
|
||||
- connect gbrain
|
||||
- start gbrain
|
||||
- configure gbrain
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
{{PREAMBLE}}
|
||||
|
||||
# /setup-gbrain — Coding-Agent Onboarding for gbrain
|
||||
|
||||
You are setting up gbrain (https://github.com/garrytan/gbrain), a persistent
|
||||
knowledge base, on the user's local Mac so that this coding agent (typically
|
||||
Claude Code) can call it as both a CLI and an MCP tool.
|
||||
|
||||
**Scope honesty:** This skill's MCP registration step (5a) uses
|
||||
`claude mcp add` and targets Claude Code specifically. Other local hosts
|
||||
(Cursor, Codex CLI, etc.) will still get the gbrain CLI on PATH — they can
|
||||
register `gbrain serve` in their own MCP config manually after setup.
|
||||
|
||||
**Audience:** local-Mac users. openclaw/hermes agents typically run in cloud
|
||||
docker containers with their own gbrain; "sharing" a brain between them and
|
||||
local Claude Code is only possible through shared Postgres (Supabase).
|
||||
|
||||
## User-invocable
|
||||
When the user types `/setup-gbrain`, run this skill. Three shortcut modes:
|
||||
|
||||
- `/setup-gbrain` — full flow (default)
|
||||
- `/setup-gbrain --repo` — only flip the per-remote policy for the current repo
|
||||
- `/setup-gbrain --switch` — only migrate the engine (PGLite ↔ Supabase)
|
||||
- `/setup-gbrain --resume-provision <ref>` — re-enter a previously interrupted
|
||||
Supabase auto-provision at the polling step
|
||||
- `/setup-gbrain --cleanup-orphans` — list + delete in-flight Supabase projects
|
||||
|
||||
Parse the invocation args yourself — these are prose hints to the skill, not
|
||||
implemented as a dispatcher binary.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Detect current state
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-detect
|
||||
```
|
||||
|
||||
Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
|
||||
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`,
|
||||
`gstack_brain_sync_mode`, `gstack_brain_git`.
|
||||
|
||||
Skip downstream steps that are already done. Report the detected state in
|
||||
one line so the user knows what you found:
|
||||
|
||||
> "Detected: gbrain v0.18.2 on PATH, engine=postgres, doctor=ok,
|
||||
> sync=artifacts-only. Nothing to install; jumping to the policy check."
|
||||
|
||||
Branch on the `--repo`, `--switch`, `--resume-provision`, `--cleanup-orphans`
|
||||
invocation flags here and skip to the matching step.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Pick a path (AskUserQuestion)
|
||||
|
||||
Only fire this if Step 1 shows no existing working config AND no shortcut
|
||||
flag was passed. The question title: "Where should your brain live?"
|
||||
|
||||
Options (present based on detected state):
|
||||
|
||||
- **1 — Supabase, I already have a connection string.** Cloud-agent users
|
||||
whose openclaw/hermes provisioned one already. Paste the Session Pooler
|
||||
URL from the Supabase dashboard (Settings → Database → Connection Pooler
|
||||
→ Session). *Trust-surface caveat to include in the prompt:* "Pasting this
|
||||
URL gives your local Claude Code full read/write access to every page your
|
||||
cloud agent can see. If that's not the trust level you want, pick PGLite
|
||||
local instead and accept the brains are disjoint."
|
||||
- **2a — Supabase, auto-provision a new project.** You'll need a Supabase
|
||||
Personal Access Token (~90 seconds). Best choice for a shared team brain.
|
||||
- **2b — Supabase, create manually.** Walk through supabase.com signup
|
||||
yourself; paste the URL back when ready.
|
||||
- **3 — PGLite local.** Zero accounts, ~30 seconds. Isolated brain on this
|
||||
Mac only. Best for try-first.
|
||||
- **Switch** (only if Step 1 detected an existing engine): "You already have
|
||||
a `<engine>` brain. Migrate it to the other engine?" → runs
|
||||
`gbrain migrate --to <other>` wrapped in `timeout 180s` (D9).
|
||||
|
||||
Do NOT silently pick; fire the AskUserQuestion.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Install gbrain CLI (if missing)
|
||||
|
||||
Only if `gbrain_on_path=false`:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-install
|
||||
```
|
||||
|
||||
The installer runs D5 detect-first (probes `~/git/gbrain`, `~/gbrain` first),
|
||||
then D19 PATH-shadow validation (post-link `gbrain --version` must match
|
||||
install-dir `package.json`). On D19 failure the installer exits 3 with a
|
||||
clear remediation menu; surface the full output to the user and STOP. Do not
|
||||
continue the skill — the environment is broken until the user fixes PATH.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Initialize the brain
|
||||
|
||||
Path-specific.
|
||||
|
||||
### Path 1 (Supabase, existing URL)
|
||||
|
||||
Source the secret-read helper, collect URL with `read -s` + redacted preview:
|
||||
|
||||
```bash
|
||||
. ~/.claude/skills/gstack/bin/gstack-gbrain-lib.sh
|
||||
read_secret_to_env GBRAIN_POOLER_URL "Paste Session Pooler URL: " \
|
||||
--echo-redacted 's#://[^@]*@#://***@#'
|
||||
```
|
||||
|
||||
Then validate structurally:
|
||||
|
||||
```bash
|
||||
printf '%s' "$GBRAIN_POOLER_URL" | ~/.claude/skills/gstack/bin/gstack-gbrain-supabase-verify -
|
||||
```
|
||||
|
||||
If the verify exit code is 3 (direct-connection URL), the verifier's own
|
||||
message explains the fix; surface it and re-prompt for a Session Pooler URL.
|
||||
|
||||
On success, hand off to gbrain via env var (D10, never argv):
|
||||
|
||||
```bash
|
||||
GBRAIN_DATABASE_URL="$GBRAIN_POOLER_URL" gbrain init --non-interactive --json
|
||||
```
|
||||
|
||||
Then `unset GBRAIN_POOLER_URL GBRAIN_DATABASE_URL` immediately. The URL is
|
||||
now persisted in `~/.gbrain/config.json` at mode 0600 by gbrain itself.
|
||||
|
||||
### Path 2a (Supabase, auto-provision — D7)
|
||||
|
||||
Show the D11 PAT scope disclosure verbatim BEFORE collecting the token:
|
||||
|
||||
> *This Supabase Personal Access Token grants full read/write/delete access
|
||||
> to every project in your Supabase account, not just the `gbrain` one we're
|
||||
> about to create. Supabase doesn't currently support scoped tokens. We use
|
||||
> this PAT only to: create one project, poll it until healthy, read the
|
||||
> Session Pooler URL — then discard it from process memory. The token
|
||||
> remains valid on Supabase's side until you manually revoke it at
|
||||
> https://supabase.com/dashboard/account/tokens — we recommend revoking
|
||||
> immediately after setup completes.*
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
. ~/.claude/skills/gstack/bin/gstack-gbrain-lib.sh
|
||||
read_secret_to_env SUPABASE_ACCESS_TOKEN "Paste PAT: "
|
||||
```
|
||||
|
||||
Ask the D17 tier prompt via AskUserQuestion: "Which Supabase tier?" Present
|
||||
Free (2-project limit, pauses after 7d inactivity) vs Pro ($25/mo, no
|
||||
pauses, recommended for real use). Explain that tier is **org-level** (per
|
||||
the Management API contract) — user picks their org based on its current
|
||||
tier. Pro may require them to upgrade the org first at supabase.com.
|
||||
|
||||
List orgs, pick one (AskUserQuestion if multiple):
|
||||
|
||||
```bash
|
||||
orgs=$(~/.claude/skills/gstack/bin/gstack-gbrain-supabase-provision list-orgs --json)
|
||||
```
|
||||
|
||||
If the `.orgs` array is empty, surface: "Your Supabase account has no
|
||||
organizations. Create one at https://supabase.com/dashboard, then re-run
|
||||
`/setup-gbrain`." STOP.
|
||||
|
||||
Ask the user for a region (default `us-east-1`; valid values are the 18
|
||||
enum values in the Supabase Management API — list a few common ones, let
|
||||
them pick "Other" for a full list).
|
||||
|
||||
Generate the DB password (never shown to the user):
|
||||
|
||||
```bash
|
||||
export DB_PASS=$(openssl rand -base64 24)
|
||||
```
|
||||
|
||||
Set up a SIGINT trap (D12 basic recovery):
|
||||
|
||||
```bash
|
||||
trap 'echo ""; echo "gstack-gbrain: interrupted. In-flight ref: $INFLIGHT_REF"; \
|
||||
echo "Resume: /setup-gbrain --resume-provision $INFLIGHT_REF"; \
|
||||
echo "Delete: https://supabase.com/dashboard/project/$INFLIGHT_REF"; \
|
||||
unset SUPABASE_ACCESS_TOKEN DB_PASS; exit 130' INT TERM
|
||||
```
|
||||
|
||||
Create + wait + fetch:
|
||||
|
||||
```bash
|
||||
result=$(~/.claude/skills/gstack/bin/gstack-gbrain-supabase-provision \
|
||||
create gbrain "$REGION" "$ORG_SLUG" --json)
|
||||
INFLIGHT_REF=$(echo "$result" | jq -r .ref)
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-supabase-provision wait "$INFLIGHT_REF" --json
|
||||
pooler=$(~/.claude/skills/gstack/bin/gstack-gbrain-supabase-provision \
|
||||
pooler-url "$INFLIGHT_REF" --json)
|
||||
GBRAIN_DATABASE_URL=$(echo "$pooler" | jq -r .pooler_url)
|
||||
export GBRAIN_DATABASE_URL
|
||||
gbrain init --non-interactive --json
|
||||
unset SUPABASE_ACCESS_TOKEN DB_PASS GBRAIN_DATABASE_URL INFLIGHT_REF
|
||||
trap - INT TERM
|
||||
```
|
||||
|
||||
After success, emit the PAT revocation reminder:
|
||||
|
||||
> "Setup complete. Revoke the PAT you pasted at
|
||||
> https://supabase.com/dashboard/account/tokens — we've already discarded
|
||||
> it from memory and don't need it again. The gbrain project will continue
|
||||
> working because it uses its own embedded database password."
|
||||
|
||||
### Path 2b (Supabase, manual)
|
||||
|
||||
Walk the user through the supabase.com steps:
|
||||
1. Login at https://supabase.com/dashboard
|
||||
2. Click "New Project," name it `gbrain`, pick a region, copy the generated
|
||||
database password (you'll need it for paste-back? no — it's embedded in
|
||||
the pooler URL we collect next)
|
||||
3. Wait ~2 min for the project to initialize
|
||||
4. Settings → Database → Connection Pooler → Session → copy the URL (port
|
||||
6543)
|
||||
|
||||
Then follow the same secret-read + verify + init flow as Path 1.
|
||||
|
||||
### Path 3 (PGLite local)
|
||||
|
||||
```bash
|
||||
gbrain init --pglite --json
|
||||
```
|
||||
|
||||
Done. No network, no secrets.
|
||||
|
||||
### Switch (from detect's existing-engine state)
|
||||
|
||||
```bash
|
||||
# Going PGLite → Supabase, collect URL first (Path 1 flow), then:
|
||||
timeout 180s gbrain migrate --to supabase --url "$URL" --json
|
||||
# Going Supabase → PGLite:
|
||||
timeout 180s gbrain migrate --to pglite --json
|
||||
```
|
||||
|
||||
If `timeout` returns 124 (exit code for timeout): surface D9 message
|
||||
("Migration didn't complete in 3 minutes — another gstack session may be
|
||||
holding a lock on the source brain. Close other workspaces and re-run
|
||||
`/setup-gbrain --switch`. Your original brain is untouched."). STOP.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Verify gbrain doctor
|
||||
|
||||
```bash
|
||||
doctor=$(gbrain doctor --json)
|
||||
status=$(echo "$doctor" | jq -r .status)
|
||||
```
|
||||
|
||||
If status is `ok` or `warnings`, proceed. Anything else → surface the full
|
||||
doctor output and STOP.
|
||||
|
||||
---
|
||||
|
||||
## Step 5a: Register gbrain as Claude Code MCP (D18)
|
||||
|
||||
Only if `which claude` resolves. Ask: "Give Claude Code a typed tool surface
|
||||
for gbrain? (recommended yes)"
|
||||
|
||||
If yes:
|
||||
|
||||
```bash
|
||||
claude mcp add gbrain -- gbrain serve
|
||||
claude mcp list | grep gbrain # verify
|
||||
```
|
||||
|
||||
If `claude` is not on PATH: emit "MCP registration skipped — this skill is
|
||||
Claude-Code-targeted; register `gbrain serve` in your agent's MCP config
|
||||
manually." Continue to step 6.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Per-remote policy (D3 triad, gated repo-import)
|
||||
|
||||
If we're in a git repo with an `origin` remote, check the policy:
|
||||
|
||||
```bash
|
||||
current_tier=$(~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy get)
|
||||
```
|
||||
|
||||
Branches:
|
||||
- `read-write` → import this repo: `gbrain import "$(pwd)" --no-embed` then
|
||||
`gbrain embed --stale &` in the background.
|
||||
- `read-only` → skip import entirely (this tier is enforced by the future
|
||||
auto-import hook + by gbrain resolver injection, not here).
|
||||
- `deny` → do nothing.
|
||||
- `unset` → AskUserQuestion: "How should `<normalized-remote>` interact with
|
||||
gbrain?"
|
||||
- `read-write` — agent can search AND write new pages from this repo
|
||||
- `read-only` — agent can search but never write
|
||||
- `deny` — no interaction at all
|
||||
- `skip-for-now` — don't persist, ask next time
|
||||
|
||||
On answer (other than skip-for-now):
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-repo-policy set "$REMOTE" "$TIER"
|
||||
```
|
||||
Then import iff `read-write`.
|
||||
|
||||
If outside a git repo OR no origin remote: skip this step with a note.
|
||||
|
||||
For `/setup-gbrain --repo` invocations, execute ONLY Step 6 and exit.
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Offer gstack-brain-sync
|
||||
|
||||
Separate AskUserQuestion: "Also sync your gstack session memory (learnings,
|
||||
plans, retros) to a private git repo that gbrain can index across machines?"
|
||||
|
||||
Options:
|
||||
- Yes, full sync (everything allowlisted)
|
||||
- Yes, artifacts-only (plans, designs, retros — skip behavioral data)
|
||||
- No thanks
|
||||
|
||||
If yes:
|
||||
|
||||
```bash
|
||||
~/.claude/skills/gstack/bin/gstack-brain-init
|
||||
~/.claude/skills/gstack/bin/gstack-config set gbrain_sync_mode artifacts-only
|
||||
# or "full" if user picked yes-full
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Persist `## GBrain Configuration` in CLAUDE.md
|
||||
|
||||
Find-and-replace (or append) this section in CLAUDE.md:
|
||||
|
||||
```markdown
|
||||
## GBrain Configuration (configured by /setup-gbrain)
|
||||
- Engine: {pglite|postgres}
|
||||
- Config file: ~/.gbrain/config.json (mode 0600)
|
||||
- Setup date: {today}
|
||||
- MCP registered: {yes/no}
|
||||
- Memory sync: {off|artifacts-only|full}
|
||||
- Current repo policy: {read-write|read-only|deny|unset}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9: Smoke test
|
||||
|
||||
```bash
|
||||
gbrain put_page --title "setup-gbrain smoke test" --tags "meta" \
|
||||
<<<"Set up on $(date)"
|
||||
gbrain search "smoke test" | grep -i "setup-gbrain smoke test"
|
||||
```
|
||||
|
||||
Confirms the round trip. On failure, surface `gbrain doctor --json` output
|
||||
and STOP with a NEEDS_CONTEXT escalation.
|
||||
|
||||
---
|
||||
|
||||
## `/setup-gbrain --cleanup-orphans` (D20)
|
||||
|
||||
Re-collect a PAT (Step 4 path-2a scope disclosure), then:
|
||||
|
||||
```bash
|
||||
# List user's Supabase projects (user has to pipe this through their own
|
||||
# shell to review; we don't rely on a stored PAT).
|
||||
export SUPABASE_ACCESS_TOKEN="<collected from read_secret_to_env>"
|
||||
projects=$(curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
https://api.supabase.com/v1/projects)
|
||||
```
|
||||
|
||||
Parse the response, identify any project named starting with `gbrain` whose
|
||||
`ref` doesn't match the user's active `~/.gbrain/config.json` pooler URL.
|
||||
For each orphan, AskUserQuestion per project: "Delete orphan project
|
||||
`<ref>` (`<name>`, created `<created_at>`)?" — NEVER batch; per-project
|
||||
confirm is a one-way door.
|
||||
|
||||
On confirmed delete:
|
||||
```bash
|
||||
curl -s -X DELETE -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
|
||||
https://api.supabase.com/v1/projects/$REF
|
||||
```
|
||||
|
||||
Never delete the active brain without a second explicit confirmation.
|
||||
|
||||
At end: `unset SUPABASE_ACCESS_TOKEN`. Revocation reminder.
|
||||
|
||||
---
|
||||
|
||||
## Telemetry (D4)
|
||||
|
||||
The preamble's Telemetry block logs skill success/failure at exit. When
|
||||
emitting the event, add these enumerated categorical values to the
|
||||
telemetry payload (SAFE — no free-form secrets, never the URL or PAT):
|
||||
|
||||
- `scenario`: `supabase-existing` | `supabase-auto-provision` |
|
||||
`supabase-manual` | `pglite-local` | `switch-to-supabase` |
|
||||
`switch-to-pglite` | `repo-flip-only` | `cleanup-orphans` |
|
||||
`resume-provision`
|
||||
- `install_performed`: `yes` | `no` (D5 reuse) | `skipped` (pre-existing)
|
||||
- `mcp_registered`: `yes` | `no` | `claude-missing`
|
||||
- `trust_tier_set`: `read-write` | `read-only` | `deny` |
|
||||
`skip-for-now` | `n/a` (outside git repo)
|
||||
|
||||
Never pass `SUPABASE_ACCESS_TOKEN`, `DB_PASS`, `GBRAIN_POOLER_URL`,
|
||||
`GBRAIN_DATABASE_URL`, or any `postgresql://` substring to the telemetry
|
||||
invocation. The CI grep test in `test/skill-validation.test.ts` enforces
|
||||
this at build time.
|
||||
|
||||
---
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **One rule for every secret.** PAT, DB_PASS, pooler URL: env-var only,
|
||||
never argv, never logged, never persisted to disk by us. The only file
|
||||
that holds the pooler URL long-term is `~/.gbrain/config.json`, written
|
||||
by gbrain's own `init` at mode 0600 — that's gbrain's discipline, not
|
||||
ours.
|
||||
- **STOP points are hard.** Gbrain doctor not healthy, D19 PATH shadow, D9
|
||||
migrate timeout, smoke test failure — each is a STOP. Do not paper over.
|
||||
- **Concurrent-run lock.** At skill start, `mkdir ~/.gstack/.setup-gbrain.lock.d`
|
||||
(atomic). If the mkdir fails, abort with: "Another `/setup-gbrain` instance
|
||||
is running. Wait for it, or `rm -rf ~/.gstack/.setup-gbrain.lock.d` if
|
||||
you're sure it's stale." Release on normal exit AND in the SIGINT trap.
|
||||
- **CLAUDE.md is the audit trail.** Always update it in Step 8 after a
|
||||
successful setup.
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* gstack-gbrain-detect + gstack-gbrain-install — Slice 2 of /setup-gbrain.
|
||||
*
|
||||
* Detect: state-reporter JSON with presence, version, config, doctor health,
|
||||
* and gstack-brain-sync mode. Pure introspection, no side effects.
|
||||
*
|
||||
* Install: D5 detect-first (reuse pre-existing clones) + D19 PATH-shadow
|
||||
* validation. The install flow itself (git clone + bun install + bun link)
|
||||
* is not exercised in CI because it touches the user's real ~/.bun/bin and
|
||||
* network. Instead we use --validate-only to exercise the D19 check and
|
||||
* --dry-run to exercise the D5 detect-first path end-to-end.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const DETECT = path.join(ROOT, 'bin', 'gstack-gbrain-detect');
|
||||
const INSTALL = path.join(ROOT, 'bin', 'gstack-gbrain-install');
|
||||
|
||||
// Minimal PATH with POSIX tools + homebrew (for jq/git/curl) but no user-bin
|
||||
// dirs — this keeps `gbrain` out of PATH deterministically across dev machines
|
||||
// while still finding jq, git, curl, sed, cat, etc. Each test can prepend a
|
||||
// fake-gbrain dir when it wants to simulate presence.
|
||||
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
|
||||
|
||||
let tmpHome: string;
|
||||
let tmpHomeReal: string;
|
||||
|
||||
type RunOpts = { env?: Record<string, string>; cwd?: string };
|
||||
function run(bin: string, args: string[], opts: RunOpts = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
GSTACK_HOME: tmpHome,
|
||||
HOME: tmpHomeReal,
|
||||
...(opts.env || {}),
|
||||
};
|
||||
const res = spawnSync(bin, args, {
|
||||
env,
|
||||
cwd: opts.cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
stderr: (res.stderr || '').trim(),
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-detect-gstack-'));
|
||||
tmpHomeReal = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-detect-home-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpHomeReal, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-detect', () => {
|
||||
test('emits valid JSON even when nothing is configured', () => {
|
||||
// Override PATH to exclude any real gbrain so the test is deterministic.
|
||||
const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-'));
|
||||
try {
|
||||
const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } });
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.gbrain_on_path).toBe(false);
|
||||
expect(j.gbrain_version).toBeNull();
|
||||
expect(j.gbrain_config_exists).toBe(false);
|
||||
expect(j.gbrain_engine).toBeNull();
|
||||
expect(j.gbrain_doctor_ok).toBe(false);
|
||||
expect(j.gstack_brain_sync_mode).toBe('off');
|
||||
expect(j.gstack_brain_git).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('reports gstack_brain_git: true when GSTACK_HOME has a .git dir', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.git'));
|
||||
const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-'));
|
||||
try {
|
||||
const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } });
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.gstack_brain_git).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('reports gbrain_config + engine when ~/.gbrain/config.json exists', () => {
|
||||
// HOME is tmpHomeReal; detect reads $HOME/.gbrain/config.json.
|
||||
fs.mkdirSync(path.join(tmpHomeReal, '.gbrain'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHomeReal, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'pglite', database_path: '/tmp/x.pglite' })
|
||||
);
|
||||
const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-'));
|
||||
try {
|
||||
const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } });
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.gbrain_config_exists).toBe(true);
|
||||
expect(j.gbrain_engine).toBe('pglite');
|
||||
} finally {
|
||||
fs.rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed config returns null engine, does not crash', () => {
|
||||
fs.mkdirSync(path.join(tmpHomeReal, '.gbrain'));
|
||||
fs.writeFileSync(path.join(tmpHomeReal, '.gbrain', 'config.json'), 'not valid json{');
|
||||
const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-'));
|
||||
try {
|
||||
const r = run(DETECT, [], { env: { PATH: `${emptyBin}:${SAFE_PATH}` } });
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.gbrain_config_exists).toBe(true);
|
||||
expect(j.gbrain_engine).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detects a mocked gbrain binary on PATH and reports its version', () => {
|
||||
const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-'));
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBin, 'gbrain'),
|
||||
'#!/bin/bash\necho "0.18.2"\nexit 0\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
try {
|
||||
const r = run(DETECT, [], { env: { PATH: `${fakeBin}:${SAFE_PATH}` } });
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.gbrain_on_path).toBe(true);
|
||||
expect(j.gbrain_version).toBe('0.18.2');
|
||||
} finally {
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-install D5 detect-first', () => {
|
||||
test('--dry-run reuses a pre-existing ~/git/gbrain-shaped clone', () => {
|
||||
// Stand up a fake ~/git/gbrain that looks valid (name + bin.gbrain).
|
||||
const fakeGit = path.join(tmpHomeReal, 'git', 'gbrain');
|
||||
fs.mkdirSync(fakeGit, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(fakeGit, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'gbrain',
|
||||
version: '0.18.2',
|
||||
bin: { gbrain: './src/cli.ts' },
|
||||
})
|
||||
);
|
||||
const r = run(INSTALL, ['--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain(`detected existing gbrain clone at ${fakeGit}`);
|
||||
expect(r.stdout).toContain('would run bun install + bun link');
|
||||
});
|
||||
|
||||
test('--dry-run falls through to fresh clone when no valid clone detected', () => {
|
||||
// No ~/git/gbrain, no ~/gbrain.
|
||||
const r = run(INSTALL, ['--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('DRY RUN: would clone');
|
||||
expect(r.stdout).toContain('https://github.com/garrytan/gbrain.git');
|
||||
});
|
||||
|
||||
test('rejects a pre-existing path that lacks a valid gbrain package.json', () => {
|
||||
// Put garbage at ~/git/gbrain, but nothing at ~/gbrain.
|
||||
const badGit = path.join(tmpHomeReal, 'git', 'gbrain');
|
||||
fs.mkdirSync(badGit, { recursive: true });
|
||||
fs.writeFileSync(path.join(badGit, 'package.json'), JSON.stringify({ name: 'not-gbrain' }));
|
||||
const r = run(INSTALL, ['--dry-run']);
|
||||
expect(r.status).toBe(0);
|
||||
// Falls through to fresh clone
|
||||
expect(r.stdout).toContain('DRY RUN: would clone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-install D19 PATH-shadow validation', () => {
|
||||
function seedInstallDir(version: string): string {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-'));
|
||||
fs.writeFileSync(
|
||||
path.join(d, 'package.json'),
|
||||
JSON.stringify({ name: 'gbrain', version, bin: { gbrain: './src/cli.ts' } })
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
function seedFakeGbrainBinary(version: string): string {
|
||||
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-bin-'));
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'gbrain'),
|
||||
`#!/bin/bash\necho "${version}"\nexit 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
test('passes when install-dir version matches `gbrain --version` on PATH', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const fakeBin = seedFakeGbrainBinary('0.18.2');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('installed gbrain 0.18.2');
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('tolerates a leading "v" in `gbrain --version` output', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const fakeBin = seedFakeGbrainBinary('v0.18.2');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails hard with exit 3 and PATH-shadow message on version mismatch', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const fakeBin = seedFakeGbrainBinary('0.18.1');
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${fakeBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('PATH SHADOWING DETECTED');
|
||||
expect(r.stderr).toContain('0.18.2');
|
||||
expect(r.stderr).toContain('0.18.1');
|
||||
// Remediation menu present
|
||||
expect(r.stderr).toContain('rm the shadowing binary');
|
||||
expect(r.stderr).toContain('prepend ~/.bun/bin to PATH');
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails hard when no gbrain on PATH after supposed install', () => {
|
||||
const installDir = seedInstallDir('0.18.2');
|
||||
const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-bin-'));
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', installDir], {
|
||||
env: { PATH: `${emptyBin}:${SAFE_PATH}` },
|
||||
});
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain("'gbrain' is not on PATH");
|
||||
} finally {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
fs.rmSync(emptyBin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails hard when install-dir package.json lacks version', () => {
|
||||
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-install-'));
|
||||
fs.writeFileSync(
|
||||
path.join(d, 'package.json'),
|
||||
JSON.stringify({ name: 'gbrain', bin: { gbrain: './src/cli.ts' } })
|
||||
);
|
||||
try {
|
||||
const r = run(INSTALL, ['--validate-only', '--install-dir', d]);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('cannot read version');
|
||||
} finally {
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-install argument handling', () => {
|
||||
test('--help prints usage without exiting non-zero', () => {
|
||||
const r = run(INSTALL, ['--help']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('gstack-gbrain-install');
|
||||
});
|
||||
|
||||
test('unknown flag exits 2 with an error message', () => {
|
||||
const r = run(INSTALL, ['--not-a-flag']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('unknown flag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* gstack-gbrain-supabase-verify + gstack-gbrain-lib.sh — Slice 3 of /setup-gbrain.
|
||||
*
|
||||
* verify: structural URL check (scheme, userinfo, host, port). No network
|
||||
* call; pure regex. Rejects direct-connection URLs with a distinct exit
|
||||
* code + UX because that's the most common paste mistake.
|
||||
*
|
||||
* lib.sh: shared secret-read helper (read_secret_to_env) sourced by the
|
||||
* skill template and by gstack-gbrain-supabase-provision. Validates var
|
||||
* name, handles stdin=TTY and stdin=pipe (CI) paths, supports optional
|
||||
* redacted-preview echo.
|
||||
*
|
||||
* Not tested here: TTY path with stty manipulation. `bun test` runs under
|
||||
* pipe stdin so [ -t 0 ] is false and the stty branches skip. That's the
|
||||
* right test matrix for CI; TTY behavior is covered by the manual test
|
||||
* matrix on a real terminal.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const VERIFY = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-verify');
|
||||
const LIB = path.join(ROOT, 'bin', 'gstack-gbrain-lib.sh');
|
||||
|
||||
function runVerify(arg: string, stdin?: string) {
|
||||
const res = spawnSync(VERIFY, arg === '' ? [] : [arg], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
stderr: (res.stderr || '').trim(),
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
// Invoke a bash snippet that sources the lib and runs something against it.
|
||||
// Returns stdout + stderr + exit code. Stdin is piped so [ -t 0 ] = false.
|
||||
function runLibSnippet(snippet: string, stdin: string = '') {
|
||||
const script = `set -euo pipefail\n. ${JSON.stringify(LIB)}\n${snippet}`;
|
||||
const res = spawnSync('bash', ['-c', script], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
stderr: (res.stderr || '').trim(),
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('gstack-gbrain-supabase-verify', () => {
|
||||
const VALID =
|
||||
'postgresql://postgres.abcdefghijklmnopqrst:secretpass@aws-0-us-east-1.pooler.supabase.com:6543/postgres';
|
||||
|
||||
test('accepts canonical Session Pooler URL', () => {
|
||||
const r = runVerify(VALID);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('ok');
|
||||
});
|
||||
|
||||
test('accepts postgres:// scheme (without ql)', () => {
|
||||
const r = runVerify(VALID.replace('postgresql://', 'postgres://'));
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
test('accepts URL via stdin with "-"', () => {
|
||||
const r = runVerify('-', VALID);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('ok');
|
||||
});
|
||||
|
||||
test('accepts URL via stdin with no argv', () => {
|
||||
const r = runVerify('', VALID);
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects direct-connection URL with exit code 3', () => {
|
||||
const url = 'postgresql://postgres:secret@db.abcdefghijk.supabase.co:5432/postgres';
|
||||
const r = runVerify(url);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('rejected direct-connection URL');
|
||||
expect(r.stderr).toContain('Session Pooler');
|
||||
// Error message should not echo the URL back (it contains a password)
|
||||
expect(r.stderr).not.toContain('secret');
|
||||
});
|
||||
|
||||
test('rejects wrong scheme', () => {
|
||||
const r = runVerify('mysql://user:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('bad scheme');
|
||||
});
|
||||
|
||||
test('rejects non-6543 port', () => {
|
||||
const r = runVerify(
|
||||
'postgresql://postgres.ref:pass@aws-0-us-east-1.pooler.supabase.com:5432/postgres'
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('6543');
|
||||
});
|
||||
|
||||
test('rejects empty password', () => {
|
||||
const r = runVerify(
|
||||
'postgresql://postgres.ref:@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('empty password');
|
||||
});
|
||||
|
||||
test('rejects missing userinfo', () => {
|
||||
const r = runVerify('postgresql://aws-0-us-east-1.pooler.supabase.com:6543/postgres');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('missing userinfo');
|
||||
});
|
||||
|
||||
test('rejects plain "postgres" user (no .ref) to catch direct-URL paste mistakes', () => {
|
||||
const r = runVerify(
|
||||
'postgresql://postgres:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain("user portion 'postgres'");
|
||||
});
|
||||
|
||||
test('rejects wrong host (not *.pooler.supabase.com)', () => {
|
||||
const r = runVerify('postgresql://postgres.ref:pass@example.com:6543/postgres');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('pooler.supabase.com');
|
||||
});
|
||||
|
||||
test('rejects empty URL', () => {
|
||||
const r = runVerify('-', '');
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('empty URL');
|
||||
});
|
||||
|
||||
test('case-insensitive host match (POOLER.SUPABASE.COM passes)', () => {
|
||||
const r = runVerify(
|
||||
'postgresql://postgres.ref:pass@AWS-0-US-EAST-1.POOLER.SUPABASE.COM:6543/postgres'
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
|
||||
test('error messages never echo the URL password', () => {
|
||||
// Supply a URL with a distinctive password; verify none of the errors
|
||||
// leak the password to stderr.
|
||||
const r = runVerify(
|
||||
'mysql://user:VERY-DISTINCT-SECRET-dk3984@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
||||
);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).not.toContain('VERY-DISTINCT-SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-lib.sh read_secret_to_env', () => {
|
||||
test('reads secret from piped stdin into the named env var', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env MY_SECRET "Enter: "
|
||||
echo "captured=[$MY_SECRET]"
|
||||
echo "len=\${#MY_SECRET}"
|
||||
`,
|
||||
'hello-world-123'
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('captured=[hello-world-123]');
|
||||
expect(r.stdout).toContain('len=15');
|
||||
});
|
||||
|
||||
test('exports the var so sub-processes see it', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env TEST_VAR "Enter: "
|
||||
bash -c 'echo "child-sees=[$TEST_VAR]"'
|
||||
`,
|
||||
'child-test-value'
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('child-sees=[child-test-value]');
|
||||
});
|
||||
|
||||
test('redacted preview uses the provided sed expression (password masked)', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env MY_URL "URL: " --echo-redacted 's#://[^@]*@#://***@#'
|
||||
echo "ok"
|
||||
`,
|
||||
'postgresql://user:SECRET123@host:5432/db'
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
// Redacted preview goes to stderr
|
||||
expect(r.stderr).toContain('Got: postgresql://***@host:5432/db');
|
||||
// Password must not appear in the preview
|
||||
expect(r.stderr).not.toContain('SECRET123');
|
||||
});
|
||||
|
||||
test('rejects invalid var names (must match [A-Z_][A-Z0-9_]*)', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env "lower-case" "Prompt: " || echo "correctly-rejected"
|
||||
`,
|
||||
'anything'
|
||||
);
|
||||
expect(r.status).toBe(0); // snippet returns 0 via the || fallback
|
||||
expect(r.stdout).toContain('correctly-rejected');
|
||||
expect(r.stderr).toContain('invalid var name');
|
||||
});
|
||||
|
||||
test('rejects var names that start with a digit', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env "1VAR" "Prompt: " || echo "correctly-rejected"
|
||||
`,
|
||||
'x'
|
||||
);
|
||||
expect(r.stdout).toContain('correctly-rejected');
|
||||
});
|
||||
|
||||
test('rejects missing args', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env || echo "correctly-rejected"
|
||||
`
|
||||
);
|
||||
expect(r.stdout).toContain('correctly-rejected');
|
||||
expect(r.stderr).toContain('usage');
|
||||
});
|
||||
|
||||
test('rejects unknown flags', () => {
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env MY_VAR "Prompt: " --unknown-flag xxx || echo "correctly-rejected"
|
||||
`,
|
||||
'x'
|
||||
);
|
||||
expect(r.stdout).toContain('correctly-rejected');
|
||||
expect(r.stderr).toContain('unknown flag');
|
||||
});
|
||||
|
||||
test('secret value never appears on stdout', () => {
|
||||
// The entire stdout comes from our `echo` statements, not read_secret_to_env.
|
||||
// Verify that an uncaptured secret doesn't leak via the prompt or anywhere.
|
||||
const r = runLibSnippet(
|
||||
`
|
||||
read_secret_to_env HIDDEN "Enter: "
|
||||
echo "len=\${#HIDDEN}"
|
||||
`,
|
||||
'this-must-not-leak-abc'
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).not.toContain('this-must-not-leak-abc');
|
||||
expect(r.stdout).toBe('len=22');
|
||||
// The prompt goes to stderr; secret must not appear there either.
|
||||
expect(r.stderr).not.toContain('this-must-not-leak-abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* gstack-gbrain-repo-policy — per-remote trust-tier policy store.
|
||||
*
|
||||
* Covers the setup-gbrain D3/D2-eng decisions end-to-end:
|
||||
* - D3 triad semantics (read-write / read-only / deny / unset)
|
||||
* - Remote-URL normalization (ssh/https/shorthand all collapse to the same key)
|
||||
* - D2-eng schema-version field (_schema_version: 2) written on new files
|
||||
* - Legacy `allow` → `read-write` migration, one-shot, idempotent
|
||||
* - Atomic writes (tmpfile + rename; no partial files visible)
|
||||
* - Corrupt-file quarantine (file renamed to .corrupt-<ts>, fresh file created)
|
||||
* - 0600 permissions on the policy file
|
||||
*
|
||||
* Each test uses a temp GSTACK_HOME so nothing leaks into the user's real ~/.gstack.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-repo-policy');
|
||||
|
||||
let tmpHome: string;
|
||||
|
||||
function run(args: string[], opts: { env?: Record<string, string> } = {}) {
|
||||
const res = spawnSync(BIN, args, {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome, ...(opts.env || {}) },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
stdout: (res.stdout || '').trim(),
|
||||
stderr: (res.stderr || '').trim(),
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function policyFile(): string {
|
||||
return path.join(tmpHome, 'gbrain-repo-policy.json');
|
||||
}
|
||||
|
||||
function readPolicy(): any {
|
||||
return JSON.parse(fs.readFileSync(policyFile(), 'utf-8'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('normalize', () => {
|
||||
test('strips https:// and .git', () => {
|
||||
const r = run(['normalize', 'https://github.com/foo/bar.git']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('github.com/foo/bar');
|
||||
});
|
||||
|
||||
test('plain https without .git', () => {
|
||||
const r = run(['normalize', 'https://github.com/foo/bar']);
|
||||
expect(r.stdout).toBe('github.com/foo/bar');
|
||||
});
|
||||
|
||||
test('ssh shorthand git@host:path collapses to the same key', () => {
|
||||
const r = run(['normalize', 'git@github.com:foo/bar.git']);
|
||||
expect(r.stdout).toBe('github.com/foo/bar');
|
||||
});
|
||||
|
||||
test('ssh:// URL form collapses to the same key', () => {
|
||||
const r = run(['normalize', 'ssh://git@github.com/foo/bar.git']);
|
||||
expect(r.stdout).toBe('github.com/foo/bar');
|
||||
});
|
||||
|
||||
test('uppercase hostname and path are lowercased', () => {
|
||||
const r = run(['normalize', 'HTTPS://GITHUB.COM/FOO/BAR']);
|
||||
expect(r.stdout).toBe('github.com/foo/bar');
|
||||
});
|
||||
|
||||
test('gitlab subgroups preserved (ssh shorthand)', () => {
|
||||
const r = run(['normalize', 'git@gitlab.com:group/subgroup/project.git']);
|
||||
expect(r.stdout).toBe('gitlab.com/group/subgroup/project');
|
||||
});
|
||||
|
||||
test('custom gitlab host with https', () => {
|
||||
const r = run(['normalize', 'https://gitlab.example.com/group/project']);
|
||||
expect(r.stdout).toBe('gitlab.example.com/group/project');
|
||||
});
|
||||
|
||||
test('all variants collapse to a single key', () => {
|
||||
const forms = [
|
||||
'https://github.com/Foo/Bar.git',
|
||||
'https://github.com/foo/bar',
|
||||
'git@github.com:foo/bar.git',
|
||||
'ssh://git@github.com/foo/bar.git',
|
||||
'HTTPS://GITHUB.COM/FOO/BAR',
|
||||
];
|
||||
const keys = forms.map((f) => run(['normalize', f]).stdout);
|
||||
expect(new Set(keys).size).toBe(1);
|
||||
expect(keys[0]).toBe('github.com/foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set + get', () => {
|
||||
test('set persists the tier and get returns it', () => {
|
||||
const s = run(['set', 'https://github.com/foo/bar.git', 'read-write']);
|
||||
expect(s.status).toBe(0);
|
||||
const g = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(g.status).toBe(0);
|
||||
expect(g.stdout).toBe('read-write');
|
||||
});
|
||||
|
||||
test('all three tier values accepted', () => {
|
||||
run(['set', 'https://github.com/a/a', 'read-write']);
|
||||
run(['set', 'https://github.com/b/b', 'read-only']);
|
||||
run(['set', 'https://github.com/c/c', 'deny']);
|
||||
expect(run(['get', 'https://github.com/a/a']).stdout).toBe('read-write');
|
||||
expect(run(['get', 'https://github.com/b/b']).stdout).toBe('read-only');
|
||||
expect(run(['get', 'https://github.com/c/c']).stdout).toBe('deny');
|
||||
});
|
||||
|
||||
test('invalid tier rejected with non-zero exit', () => {
|
||||
const r = run(['set', 'https://github.com/foo/bar', 'allow']);
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.stderr.toLowerCase()).toContain('invalid tier');
|
||||
});
|
||||
|
||||
test('get for unset remote returns literal unset', () => {
|
||||
run(['set', 'https://github.com/foo/bar', 'read-write']);
|
||||
const r = run(['get', 'https://github.com/baz/qux']);
|
||||
expect(r.stdout).toBe('unset');
|
||||
});
|
||||
|
||||
test('ssh-set then https-get returns the same tier', () => {
|
||||
run(['set', 'git@github.com:foo/bar.git', 'deny']);
|
||||
const r = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(r.stdout).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file format + schema version', () => {
|
||||
test('_schema_version: 2 added on fresh file creation', () => {
|
||||
run(['set', 'https://github.com/foo/bar', 'read-write']);
|
||||
expect(readPolicy()._schema_version).toBe(2);
|
||||
});
|
||||
|
||||
test('policy file mode is 0600', () => {
|
||||
run(['set', 'https://github.com/foo/bar', 'read-write']);
|
||||
const mode = fs.statSync(policyFile()).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
test('re-running set does not duplicate schema version or entries', () => {
|
||||
run(['set', 'https://github.com/foo/bar', 'read-write']);
|
||||
run(['set', 'https://github.com/foo/bar', 'deny']);
|
||||
const p = readPolicy();
|
||||
expect(p._schema_version).toBe(2);
|
||||
expect(p['github.com/foo/bar']).toBe('deny');
|
||||
// Only the schema version + the one entry
|
||||
expect(Object.keys(p).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy migration (D3 allow → read-write)', () => {
|
||||
test('legacy allow value is rewritten to read-write on first read', () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ 'github.com/foo/bar': 'allow' }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const r = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(r.stdout).toBe('read-write');
|
||||
expect(r.stderr).toContain('Migrated 1 legacy allow entries');
|
||||
const p = readPolicy();
|
||||
expect(p['github.com/foo/bar']).toBe('read-write');
|
||||
expect(p._schema_version).toBe(2);
|
||||
});
|
||||
|
||||
test('migration preserves deny entries unchanged', () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ 'github.com/foo/bar': 'allow', 'github.com/baz/qux': 'deny' }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
run(['get', 'https://github.com/foo/bar']);
|
||||
const p = readPolicy();
|
||||
expect(p['github.com/foo/bar']).toBe('read-write');
|
||||
expect(p['github.com/baz/qux']).toBe('deny');
|
||||
});
|
||||
|
||||
test('migration is idempotent — second run is a no-op', () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ 'github.com/foo/bar': 'allow' }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const first = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(first.stderr).toContain('Migrated 1');
|
||||
const second = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(second.stderr).not.toContain('Migrated');
|
||||
expect(second.stdout).toBe('read-write');
|
||||
});
|
||||
|
||||
test('already-v2 file is not re-migrated', () => {
|
||||
fs.writeFileSync(
|
||||
policyFile(),
|
||||
JSON.stringify({ _schema_version: 2, 'github.com/foo/bar': 'read-write' }),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const r = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(r.stderr).not.toContain('Migrated');
|
||||
expect(r.stdout).toBe('read-write');
|
||||
});
|
||||
});
|
||||
|
||||
describe('corrupt-file handling', () => {
|
||||
test('unparseable JSON is quarantined and a fresh file is started', () => {
|
||||
fs.writeFileSync(policyFile(), 'not valid json{', { mode: 0o600 });
|
||||
const r = run(['get', 'https://github.com/foo/bar']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('unset');
|
||||
expect(r.stderr).toContain('corrupt policy file quarantined');
|
||||
// New file exists, is valid, and has schema version
|
||||
const p = readPolicy();
|
||||
expect(p._schema_version).toBe(2);
|
||||
// Quarantine file exists
|
||||
const quarantine = fs.readdirSync(tmpHome).find((f) =>
|
||||
f.startsWith('gbrain-repo-policy.json.corrupt-')
|
||||
);
|
||||
expect(quarantine).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('list prints entries sorted, excludes _schema_version', () => {
|
||||
run(['set', 'https://github.com/zebra/zz', 'deny']);
|
||||
run(['set', 'https://github.com/apple/aa', 'read-write']);
|
||||
run(['set', 'https://github.com/middle/mm', 'read-only']);
|
||||
const r = run(['list']);
|
||||
const lines = r.stdout.split('\n');
|
||||
expect(lines.length).toBe(3);
|
||||
expect(lines[0]).toBe('github.com/apple/aa\tread-write');
|
||||
expect(lines[1]).toBe('github.com/middle/mm\tread-only');
|
||||
expect(lines[2]).toBe('github.com/zebra/zz\tdeny');
|
||||
});
|
||||
|
||||
test('list on missing file returns empty, no file created', () => {
|
||||
const r = run(['list']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(fs.existsSync(policyFile())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get without arg (auto-detect from current dir)', () => {
|
||||
test('returns unset when not in a git repo', () => {
|
||||
const cwdTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'no-git-'));
|
||||
try {
|
||||
const res = spawnSync(BIN, ['get'], {
|
||||
env: { ...process.env, GSTACK_HOME: tmpHome },
|
||||
cwd: cwdTmp,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect((res.stdout || '').trim()).toBe('unset');
|
||||
} finally {
|
||||
fs.rmSync(cwdTmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* gstack-gbrain-supabase-provision — Supabase Management API wrapper.
|
||||
*
|
||||
* All tests run against a per-test local mock HTTP server (Bun.serve)
|
||||
* that returns fixture responses. Never hits the real Supabase API, never
|
||||
* requires a live PAT.
|
||||
*
|
||||
* Covers the D21 HTTP error suite (401/403/402/409/429/5xx), the happy
|
||||
* path for each subcommand (list-orgs, create, wait, pooler-url), the
|
||||
* verified schema corrections (POST /v1/projects with organization_slug,
|
||||
* GET /config/database/pooler), PAT + DB_PASS env-var discipline, retry
|
||||
* + backoff on transient errors, pooler URL construction using the
|
||||
* generated DB_PASS (not the API response's templated connection_string).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-provision');
|
||||
|
||||
// Minimal PATH that finds jq/curl but excludes user bins.
|
||||
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin';
|
||||
|
||||
type Handler = (req: Request) => Response | Promise<Response>;
|
||||
|
||||
interface MockServer {
|
||||
url: string;
|
||||
close: () => void;
|
||||
requests: Array<{ method: string; path: string; body?: string }>;
|
||||
}
|
||||
|
||||
function startMock(routes: Record<string, Handler>): MockServer {
|
||||
const requests: MockServer['requests'] = [];
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
const key = `${req.method} ${u.pathname}`;
|
||||
// Log method+path only. Handlers that need the body read it themselves;
|
||||
// Response bodies can only be consumed once.
|
||||
requests.push({ method: req.method, path: u.pathname });
|
||||
const handler = routes[key] || routes[`${req.method} *`];
|
||||
if (!handler) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: `no mock for ${key}` }),
|
||||
{ status: 404, headers: { 'content-type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
return handler(req);
|
||||
},
|
||||
});
|
||||
const base = `http://localhost:${server.port}`;
|
||||
return {
|
||||
url: base,
|
||||
close: () => server.stop(true),
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBin(
|
||||
args: string[],
|
||||
env: Record<string, string> = {}
|
||||
): Promise<{ stdout: string; stderr: string; status: number }> {
|
||||
// Use Bun.spawn (async) rather than spawnSync. spawnSync blocks the Bun
|
||||
// event loop, which prevents Bun.serve mocks from responding — every
|
||||
// HTTP call would hit curl's timeout instead of round-tripping.
|
||||
const proc = Bun.spawn([BIN, ...args], {
|
||||
env: { PATH: SAFE_PATH, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [stdout, stderr, status] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), status };
|
||||
}
|
||||
|
||||
function jsonResp(body: any, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let mock: MockServer;
|
||||
|
||||
afterEach(() => {
|
||||
if (mock) mock.close();
|
||||
});
|
||||
|
||||
describe('list-orgs', () => {
|
||||
test('happy path: returns orgs from GET /v1/organizations', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/organizations': () =>
|
||||
jsonResp([
|
||||
{ id: 'deprec-1', slug: 'acme', name: 'Acme Inc' },
|
||||
{ id: 'deprec-2', slug: 'personal', name: 'Personal' },
|
||||
]),
|
||||
});
|
||||
const r = await runBin(['list-orgs', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test_pat',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.orgs).toEqual([
|
||||
{ slug: 'acme', name: 'Acme Inc' },
|
||||
{ slug: 'personal', name: 'Personal' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sends Authorization: Bearer <PAT> header', async () => {
|
||||
let authHeader = '';
|
||||
mock = startMock({
|
||||
'GET /v1/organizations': (req) => {
|
||||
authHeader = req.headers.get('authorization') || '';
|
||||
return jsonResp([]);
|
||||
},
|
||||
});
|
||||
await runBin(['list-orgs', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_expected_pat_xxx',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(authHeader).toBe('Bearer sbp_expected_pat_xxx');
|
||||
});
|
||||
|
||||
test('exits 3 with auth error when SUPABASE_ACCESS_TOKEN is missing', async () => {
|
||||
const r = await runBin(['list-orgs']);
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('SUPABASE_ACCESS_TOKEN is not set');
|
||||
});
|
||||
|
||||
test('exits 3 on 401 Unauthorized', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/organizations': () => jsonResp({ message: 'Invalid JWT' }, 401),
|
||||
});
|
||||
const r = await runBin(['list-orgs'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_bad',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('401 Unauthorized');
|
||||
});
|
||||
|
||||
test('exits 3 on 403 Forbidden', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/organizations': () => jsonResp({ message: 'Forbidden' }, 403),
|
||||
});
|
||||
const r = await runBin(['list-orgs'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_noperm',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.stderr).toContain('403 Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
test('happy path: POST /v1/projects with organization_slug, no `plan` field', async () => {
|
||||
let sentBody: any = null;
|
||||
mock = startMock({
|
||||
'POST /v1/projects': async (req) => {
|
||||
sentBody = JSON.parse(await req.text());
|
||||
return jsonResp({
|
||||
id: 'deprec',
|
||||
ref: 'abcdefghijklmnopqrst',
|
||||
organization_slug: 'acme',
|
||||
name: 'gbrain',
|
||||
region: 'us-east-1',
|
||||
created_at: '2026-04-23T00:00:00Z',
|
||||
status: 'COMING_UP',
|
||||
}, 201);
|
||||
},
|
||||
});
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'generated-secret-pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.ref).toBe('abcdefghijklmnopqrst');
|
||||
expect(j.status).toBe('COMING_UP');
|
||||
// Verify the request body had the right shape
|
||||
expect(sentBody.name).toBe('gbrain');
|
||||
expect(sentBody.region).toBe('us-east-1');
|
||||
expect(sentBody.organization_slug).toBe('acme');
|
||||
expect(sentBody.db_pass).toBe('generated-secret-pw');
|
||||
// Critical: no `plan` field, since it's ignored server-side per OpenAPI
|
||||
expect(sentBody.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
test('passes desired_instance_size when --instance-size flag is used', async () => {
|
||||
let sentBody: any = null;
|
||||
mock = startMock({
|
||||
'POST /v1/projects': async (req) => {
|
||||
sentBody = JSON.parse(await req.text());
|
||||
return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201);
|
||||
},
|
||||
});
|
||||
await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--instance-size', 'small', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(sentBody.desired_instance_size).toBe('small');
|
||||
});
|
||||
|
||||
test('exits 4 on 402 Payment Required (quota)', async () => {
|
||||
mock = startMock({
|
||||
'POST /v1/projects': () => jsonResp({ message: 'project limit reached' }, 402),
|
||||
});
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(4);
|
||||
expect(r.stderr).toContain('402 Payment Required');
|
||||
expect(r.stderr).toContain('quota exceeded');
|
||||
});
|
||||
|
||||
test('exits 5 on 409 Conflict (duplicate name)', async () => {
|
||||
mock = startMock({
|
||||
'POST /v1/projects': () => jsonResp({ message: 'conflict' }, 409),
|
||||
});
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(5);
|
||||
expect(r.stderr).toContain('409 Conflict');
|
||||
expect(r.stderr).toContain('duplicate project name');
|
||||
});
|
||||
|
||||
test('fails when DB_PASS is missing', async () => {
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('DB_PASS env var is required');
|
||||
});
|
||||
|
||||
test('missing positional args rejected with exit 2', async () => {
|
||||
const r = await runBin(['create', 'gbrain'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('missing');
|
||||
});
|
||||
|
||||
test('retries on 429 rate limit with backoff and eventually succeeds', async () => {
|
||||
let count = 0;
|
||||
mock = startMock({
|
||||
'POST /v1/projects': () => {
|
||||
count += 1;
|
||||
if (count < 2) return jsonResp({ message: 'too many requests' }, 429);
|
||||
return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201);
|
||||
},
|
||||
});
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(count).toBe(2);
|
||||
}, 15000);
|
||||
|
||||
test('exits 8 on persistent 5xx after max retries', async () => {
|
||||
let count = 0;
|
||||
mock = startMock({
|
||||
'POST /v1/projects': () => {
|
||||
count += 1;
|
||||
return jsonResp({ message: 'internal server error' }, 502);
|
||||
},
|
||||
});
|
||||
const r = await runBin(['create', 'gbrain', 'us-east-1', 'acme'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(8);
|
||||
expect(r.stderr).toContain('502');
|
||||
expect(count).toBeGreaterThanOrEqual(3);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('wait', () => {
|
||||
test('happy path: polls until ACTIVE_HEALTHY', async () => {
|
||||
let count = 0;
|
||||
mock = startMock({
|
||||
'GET /v1/projects/abc': () => {
|
||||
count += 1;
|
||||
if (count < 2) return jsonResp({ ref: 'abc', status: 'COMING_UP' });
|
||||
return jsonResp({ ref: 'abc', status: 'ACTIVE_HEALTHY' });
|
||||
},
|
||||
});
|
||||
const r = await runBin(['wait', 'abc', '--timeout', '30', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('ACTIVE_HEALTHY');
|
||||
expect(j.ref).toBe('abc');
|
||||
}, 30000);
|
||||
|
||||
test('exits 7 on terminal INIT_FAILED state', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'INIT_FAILED' }),
|
||||
});
|
||||
const r = await runBin(['wait', 'abc', '--timeout', '10'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(7);
|
||||
expect(r.stderr).toContain('INIT_FAILED');
|
||||
});
|
||||
|
||||
test('exits 6 on timeout with resume-provision hint', async () => {
|
||||
// Stay in COMING_UP forever.
|
||||
mock = startMock({
|
||||
'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'COMING_UP' }),
|
||||
});
|
||||
const r = await runBin(['wait', 'abc', '--timeout', '0'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(6);
|
||||
expect(r.stderr).toContain('wait timed out');
|
||||
expect(r.stderr).toContain('--resume-provision abc');
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('pooler-url', () => {
|
||||
const REF = 'abcdefghijklmnopqrst';
|
||||
const POOLER_OK = {
|
||||
db_user: `postgres.${REF}`,
|
||||
db_host: 'aws-0-us-east-1.pooler.supabase.com',
|
||||
db_port: 6543,
|
||||
db_name: 'postgres',
|
||||
pool_mode: 'session',
|
||||
connection_string:
|
||||
'postgresql://postgres.abcdefghijklmnopqrst:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres',
|
||||
};
|
||||
|
||||
test('constructs URL from db_user/host/port/name + DB_PASS (not response connection_string)', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'my-real-password',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.pooler_url).toBe(
|
||||
`postgresql://postgres.${REF}:my-real-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres`
|
||||
);
|
||||
// The API's templated connection_string is NOT what we output.
|
||||
expect(j.pooler_url).not.toContain('[PASSWORD]');
|
||||
});
|
||||
|
||||
test('handles array response by preferring session pool_mode entry', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp([
|
||||
{ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 },
|
||||
{ ...POOLER_OK, pool_mode: 'session', db_port: 5432 },
|
||||
]),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF, '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
// Picked session entry with port 5432 (for this fixture)
|
||||
expect(j.pooler_url).toContain(':5432/postgres');
|
||||
});
|
||||
|
||||
test('fails cleanly when pooler config is missing required fields', async () => {
|
||||
mock = startMock({
|
||||
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
|
||||
jsonResp({ identifier: 'x', pool_mode: 'session' }),
|
||||
});
|
||||
const r = await runBin(['pooler-url', REF], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
DB_PASS: 'pw',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('missing pooler config fields');
|
||||
});
|
||||
|
||||
test('requires DB_PASS to construct URL', async () => {
|
||||
const r = await runBin(['pooler-url', REF], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('DB_PASS env var is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list-orphans (D20)', () => {
|
||||
const MOCK_PROJECTS = [
|
||||
{ ref: 'aaaaaaaaaaaaaaaaaaaa', name: 'gbrain', created_at: '2026-04-20', region: 'us-east-1' },
|
||||
{ ref: 'bbbbbbbbbbbbbbbbbbbb', name: 'gbrain-backup', created_at: '2026-04-21', region: 'us-east-1' },
|
||||
{ ref: 'cccccccccccccccccccc', name: 'my-production', created_at: '2026-04-15', region: 'us-west-2' },
|
||||
{ ref: 'dddddddddddddddddddd', name: 'gbrain', created_at: '2026-04-22', region: 'eu-west-1' },
|
||||
];
|
||||
|
||||
test('lists gbrain-prefixed projects that are NOT the active brain', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/projects': () => jsonResp(MOCK_PROJECTS),
|
||||
});
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-orphan-'));
|
||||
// use top-level fs
|
||||
fs.mkdirSync(path.join(home, '.gbrain'));
|
||||
fs.writeFileSync(
|
||||
path.join(home, '.gbrain', 'config.json'),
|
||||
JSON.stringify({
|
||||
engine: 'postgres',
|
||||
// Active brain points at aaaaaaaaaaaaaaaaaaaa
|
||||
database_url: 'postgresql://postgres.aaaaaaaaaaaaaaaaaaaa:pw@host:6543/postgres',
|
||||
})
|
||||
);
|
||||
try {
|
||||
const r = await runBin(['list-orphans', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
HOME: home,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.active_ref).toBe('aaaaaaaaaaaaaaaaaaaa');
|
||||
expect(j.orphans.length).toBe(2);
|
||||
const refs = j.orphans.map((o: any) => o.ref).sort();
|
||||
expect(refs).toEqual(['bbbbbbbbbbbbbbbbbbbb', 'dddddddddddddddddddd']);
|
||||
// my-production is NOT in orphans — filtered out by gbrain prefix
|
||||
expect(refs).not.toContain('cccccccccccccccccccc');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('treats all gbrain-prefixed projects as orphans when no active config exists', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/projects': () => jsonResp(MOCK_PROJECTS),
|
||||
});
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-no-cfg-'));
|
||||
try {
|
||||
const r = await runBin(['list-orphans', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
HOME: home,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.active_ref).toBeNull();
|
||||
// All 3 gbrain-prefixed projects are orphans when no active config
|
||||
expect(j.orphans.length).toBe(3);
|
||||
} finally {
|
||||
// use top-level fs
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('respects custom --name-prefix', async () => {
|
||||
mock = startMock({
|
||||
'GET /v1/projects': () =>
|
||||
jsonResp([
|
||||
{ ref: 'aaaaaaaaaaaaaaaaaaaa', name: 'my-prefix-one', created_at: '2026-04-20' },
|
||||
{ ref: 'bbbbbbbbbbbbbbbbbbbb', name: 'gbrain', created_at: '2026-04-20' },
|
||||
]),
|
||||
});
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-prefix-'));
|
||||
try {
|
||||
const r = await runBin(['list-orphans', '--name-prefix', 'my-prefix', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
HOME: home,
|
||||
});
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.orphans.length).toBe(1);
|
||||
expect(j.orphans[0].name).toBe('my-prefix-one');
|
||||
} finally {
|
||||
// use top-level fs
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete-project (D20)', () => {
|
||||
test('issues DELETE /v1/projects/<ref> and returns the deleted ref', async () => {
|
||||
let deletedPath = '';
|
||||
mock = startMock({
|
||||
'DELETE /v1/projects/abcdefghijklmnopqrst': (req) => {
|
||||
deletedPath = new URL(req.url).pathname;
|
||||
return jsonResp({ id: 1, ref: 'abcdefghijklmnopqrst', name: 'gbrain' });
|
||||
},
|
||||
});
|
||||
const r = await runBin(['delete-project', 'abcdefghijklmnopqrst', '--json'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(deletedPath).toBe('/v1/projects/abcdefghijklmnopqrst');
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.deleted_ref).toBe('abcdefghijklmnopqrst');
|
||||
});
|
||||
|
||||
test('surfaces 404 when the project does not exist', async () => {
|
||||
mock = startMock({
|
||||
'DELETE /v1/projects/nonexistent': () => jsonResp({ message: 'Project not found' }, 404),
|
||||
});
|
||||
const r = await runBin(['delete-project', 'nonexistent'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
SUPABASE_API_BASE: mock.url,
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('404');
|
||||
});
|
||||
|
||||
test('requires a ref', async () => {
|
||||
const r = await runBin(['delete-project'], {
|
||||
SUPABASE_ACCESS_TOKEN: 'sbp_test',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('general', () => {
|
||||
test('unknown subcommand exits 2', async () => {
|
||||
const r = await runBin(['nope']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('unknown subcommand');
|
||||
});
|
||||
|
||||
test('no args prints usage and exits 2', async () => {
|
||||
const r = await runBin([]);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('usage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* gstack-brain-init — mocked-gh integration tests.
|
||||
*
|
||||
* The regular brain-sync tests pass `--remote <bare-git-url>` to skip the
|
||||
* gh-repo-creation path entirely. That left the happy path (user just
|
||||
* presses Enter, gstack-brain-init calls `gh repo create --private`)
|
||||
* with zero coverage — you'd only know it broke when a real user tried
|
||||
* it with a real GitHub account.
|
||||
*
|
||||
* These tests put a fake `gh` binary on PATH that records every call
|
||||
* into a file, then run gstack-brain-init in its non-flag interactive
|
||||
* mode and assert the fake `gh` was invoked with the expected arguments.
|
||||
*
|
||||
* No real GitHub account, no live API, deterministic per-run.
|
||||
*/
|
||||
|
||||
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 { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN_DIR = path.join(ROOT, 'bin');
|
||||
const INIT_BIN = path.join(BIN_DIR, 'gstack-brain-init');
|
||||
|
||||
let tmpHome: string;
|
||||
let bareRemote: string;
|
||||
let fakeBinDir: string;
|
||||
let ghCallLog: string;
|
||||
|
||||
function makeFakeGh(opts: {
|
||||
authStatus?: 'ok' | 'fail';
|
||||
repoCreate?: 'success' | 'already-exists' | 'fail';
|
||||
sshUrl?: string;
|
||||
}) {
|
||||
const authStatus = opts.authStatus ?? 'ok';
|
||||
const repoCreate = opts.repoCreate ?? 'success';
|
||||
const sshUrl = opts.sshUrl ?? bareRemote;
|
||||
const script = `#!/bin/bash
|
||||
echo "gh $@" >> "${ghCallLog}"
|
||||
case "$1" in
|
||||
auth)
|
||||
${authStatus === 'ok' ? 'exit 0' : 'exit 1'}
|
||||
;;
|
||||
repo)
|
||||
shift
|
||||
case "$1" in
|
||||
create)
|
||||
${
|
||||
repoCreate === 'success'
|
||||
? 'exit 0'
|
||||
: repoCreate === 'already-exists'
|
||||
? 'echo "GraphQL: Name already exists on this account" >&2; exit 1'
|
||||
: 'echo "network error" >&2; exit 1'
|
||||
}
|
||||
;;
|
||||
view)
|
||||
# Emulate \`gh repo view <name> --json sshUrl -q .sshUrl\`
|
||||
echo "${sshUrl}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`;
|
||||
const ghPath = path.join(fakeBinDir, 'gh');
|
||||
fs.writeFileSync(ghPath, script, { mode: 0o755 });
|
||||
return ghPath;
|
||||
}
|
||||
|
||||
function run(
|
||||
argv: string[],
|
||||
opts: { env?: Record<string, string>; input?: string } = {}
|
||||
) {
|
||||
const env = {
|
||||
// Put the fake bin dir FIRST on PATH so our mock gh wins.
|
||||
PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin`,
|
||||
GSTACK_HOME: tmpHome,
|
||||
USER: 'testuser',
|
||||
HOME: tmpHome,
|
||||
...(opts.env || {}),
|
||||
};
|
||||
const res = spawnSync(INIT_BIN, argv, {
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
input: opts.input,
|
||||
cwd: ROOT,
|
||||
});
|
||||
return {
|
||||
stdout: res.stdout || '',
|
||||
stderr: res.stderr || '',
|
||||
status: res.status ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function readGhCalls(): string[] {
|
||||
if (!fs.existsSync(ghCallLog)) return [];
|
||||
return fs.readFileSync(ghCallLog, 'utf-8').trim().split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-gh-mock-'));
|
||||
bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-bare-'));
|
||||
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-fake-bin-'));
|
||||
ghCallLog = path.join(fakeBinDir, 'gh-calls.log');
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(bareRemote, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
const remoteFile = path.join(os.homedir(), '.gstack-brain-remote.txt');
|
||||
if (fs.existsSync(remoteFile)) {
|
||||
const contents = fs.readFileSync(remoteFile, 'utf-8');
|
||||
if (contents.includes(bareRemote)) fs.unlinkSync(remoteFile);
|
||||
}
|
||||
});
|
||||
|
||||
describe('gstack-brain-init uses gh CLI when present + authed', () => {
|
||||
test('calls gh repo create --private with the computed default name', () => {
|
||||
makeFakeGh({ authStatus: 'ok', repoCreate: 'success' });
|
||||
// Interactive mode; pressing Enter accepts the gh default.
|
||||
const r = run([], { input: '\n' });
|
||||
expect(r.status).toBe(0);
|
||||
const calls = readGhCalls();
|
||||
// First call: auth status check
|
||||
expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true);
|
||||
// The create call
|
||||
const createCall = calls.find((c) => c.startsWith('gh repo create'));
|
||||
expect(createCall).toBeDefined();
|
||||
expect(createCall).toContain('gstack-brain-testuser');
|
||||
expect(createCall).toContain('--private');
|
||||
expect(createCall).toContain('--description');
|
||||
expect(createCall).toContain('--source');
|
||||
expect(createCall).toContain(tmpHome);
|
||||
});
|
||||
|
||||
test('falls back to gh repo view when create reports already-exists', () => {
|
||||
makeFakeGh({ authStatus: 'ok', repoCreate: 'already-exists' });
|
||||
const r = run([], { input: '\n' });
|
||||
expect(r.status).toBe(0);
|
||||
const calls = readGhCalls();
|
||||
// create was attempted
|
||||
expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(true);
|
||||
// then view was called to recover the URL
|
||||
expect(calls.some((c) => c.startsWith('gh repo view') && c.includes('gstack-brain-testuser'))).toBe(true);
|
||||
// The view output (bareRemote URL) should have been wired up as origin.
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(remote.stdout.trim()).toBe(bareRemote);
|
||||
});
|
||||
|
||||
test('user-provided URL bypasses gh create entirely', () => {
|
||||
makeFakeGh({ authStatus: 'ok', repoCreate: 'fail' });
|
||||
const r = run([], { input: `${bareRemote}\n` });
|
||||
expect(r.status).toBe(0);
|
||||
const calls = readGhCalls();
|
||||
// gh auth was still checked
|
||||
expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true);
|
||||
// but create was NOT called (user bypassed the default)
|
||||
expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-brain-init without gh CLI', () => {
|
||||
test('prompts for URL when gh is not on PATH', () => {
|
||||
// Don't install fake gh — PATH will not have it.
|
||||
// Use a bare-minimum PATH so nothing else shadows.
|
||||
const stripped = `${fakeBinDir}:/usr/bin:/bin`;
|
||||
const res = spawnSync(INIT_BIN, [], {
|
||||
env: {
|
||||
PATH: stripped,
|
||||
GSTACK_HOME: tmpHome,
|
||||
USER: 'testuser',
|
||||
HOME: tmpHome,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
input: `${bareRemote}\n`,
|
||||
cwd: ROOT,
|
||||
});
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toContain('gh CLI not found');
|
||||
// Remote got set from the stdin paste
|
||||
const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(remote.stdout.trim()).toBe(bareRemote);
|
||||
});
|
||||
|
||||
test('prompts for URL when gh is present but not authed', () => {
|
||||
makeFakeGh({ authStatus: 'fail' });
|
||||
const r = run([], { input: `${bareRemote}\n` });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('gh CLI not found or not authenticated');
|
||||
const calls = readGhCalls();
|
||||
// Only `gh auth status` was called; no create attempt.
|
||||
expect(calls.some((c) => c.startsWith('gh auth'))).toBe(true);
|
||||
expect(calls.some((c) => c.startsWith('gh repo create'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency via flag', () => {
|
||||
test('--remote <url> skips all gh calls', () => {
|
||||
makeFakeGh({ authStatus: 'ok', repoCreate: 'success' });
|
||||
const r = run(['--remote', bareRemote]);
|
||||
expect(r.status).toBe(0);
|
||||
const calls = readGhCalls();
|
||||
// Zero calls to gh — the --remote flag short-circuits the interactive path.
|
||||
expect(calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('re-run with matching --remote is safe (no conflicting-remote error)', () => {
|
||||
run(['--remote', bareRemote]);
|
||||
const r2 = run(['--remote', bareRemote]);
|
||||
expect(r2.status).toBe(0);
|
||||
});
|
||||
|
||||
test('re-run with DIFFERENT --remote exits 1 with a conflict message', () => {
|
||||
run(['--remote', bareRemote]);
|
||||
const otherRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-init-other-'));
|
||||
spawnSync('git', ['init', '--bare', '-q', '-b', 'main', otherRemote]);
|
||||
try {
|
||||
const r2 = run(['--remote', otherRemote]);
|
||||
expect(r2.status).not.toBe(0);
|
||||
expect(r2.stderr).toContain('already a git repo');
|
||||
} finally {
|
||||
fs.rmSync(otherRemote, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Secret-sink test harness (D21 #5, D1-eng contract).
|
||||
*
|
||||
* Runs a bin with a seeded secret, captures every channel the bin could
|
||||
* leak through, and asserts that the seed never appears. Used by Slice 6
|
||||
* tests and available for future skills that handle secrets.
|
||||
*
|
||||
* Channels covered:
|
||||
* - stdout (Bun.spawn pipe)
|
||||
* - stderr (Bun.spawn pipe)
|
||||
* - files written under a per-run $HOME (walked post-mortem)
|
||||
* - telemetry JSONL under $HOME/.gstack/analytics/ (same walk, but called
|
||||
* out separately for clearer test failures)
|
||||
*
|
||||
* Match rules (any hit = leak):
|
||||
* - exact substring
|
||||
* - URL-decoded substring (catches percent-encoded leaks)
|
||||
* - first-12-char prefix (catches "we logged just a portion")
|
||||
* - base64 encoding of the seed (catches auth-header leakage)
|
||||
*
|
||||
* Intentionally NOT covered in v1:
|
||||
* - subprocess environment dump (portable /proc reading is non-trivial;
|
||||
* bins rarely leak env without also writing to stdout/stderr)
|
||||
* - the user's real shell history (bins don't modify it; the user's
|
||||
* shell does)
|
||||
* Those are documented as follow-ups in the D21 eng review commentary.
|
||||
*
|
||||
* Positive-control discipline: every test suite using this harness should
|
||||
* include one test that deliberately leaks a seed and asserts the harness
|
||||
* catches it. A harness that silently under-reports is worse than no
|
||||
* harness.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface SecretSinkOptions {
|
||||
bin: string;
|
||||
args: string[];
|
||||
/** Seeds whose presence in any captured channel = failure. */
|
||||
seeds: string[];
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
/** Override the tmp $HOME. Default: fresh mkdtemp under os.tmpdir(). */
|
||||
tmpHome?: string;
|
||||
/** Cap on subprocess runtime, ms. Default 10_000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface Leak {
|
||||
channel: 'stdout' | 'stderr' | 'file' | 'telemetry';
|
||||
matchType: 'exact' | 'url-decoded' | 'prefix-12' | 'base64';
|
||||
/** For channel=file|telemetry: the path relative to tmpHome. */
|
||||
where?: string;
|
||||
/** Short excerpt around the match (for debugging). */
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface SinkResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
/** All files written under tmpHome during the run, keyed by relative path. */
|
||||
filesWritten: Record<string, string>;
|
||||
/** Subset of filesWritten matching .gstack/analytics/*.jsonl. */
|
||||
telemetry: Record<string, string>;
|
||||
/** Leaks discovered. Empty = clean. */
|
||||
leaks: Leak[];
|
||||
/** Where HOME was pointed during the run (for post-mortem inspection). */
|
||||
tmpHome: string;
|
||||
}
|
||||
|
||||
export async function runWithSecretSink(opts: SecretSinkOptions): Promise<SinkResult> {
|
||||
const tmpHome = opts.tmpHome ?? fs.mkdtempSync(path.join(os.tmpdir(), 'sink-'));
|
||||
// Make sure .gstack exists so bins that append to analytics have somewhere to write.
|
||||
fs.mkdirSync(path.join(tmpHome, '.gstack', 'analytics'), { recursive: true });
|
||||
|
||||
const env = {
|
||||
// Minimal PATH that still finds jq/git/curl/sed so our bins work.
|
||||
PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin',
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: path.join(tmpHome, '.gstack'),
|
||||
...(opts.env || {}),
|
||||
};
|
||||
|
||||
const proc = Bun.spawn([opts.bin, ...opts.args], {
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdin: opts.stdin ? 'pipe' : 'ignore',
|
||||
});
|
||||
if (opts.stdin) {
|
||||
proc.stdin!.write(opts.stdin);
|
||||
proc.stdin!.end();
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
try { proc.kill(); } catch { /* already done */ }
|
||||
}, timeoutMs);
|
||||
|
||||
const [stdout, stderr, status] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
// Walk tmpHome and read all files (skip binaries / very large files).
|
||||
const filesWritten: Record<string, string> = {};
|
||||
const telemetry: Record<string, string> = {};
|
||||
walk(tmpHome, tmpHome, filesWritten);
|
||||
for (const [rel, content] of Object.entries(filesWritten)) {
|
||||
if (rel.startsWith('.gstack/analytics/') && rel.endsWith('.jsonl')) {
|
||||
telemetry[rel] = content;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan every channel for every seed with every match rule.
|
||||
const leaks: Leak[] = [];
|
||||
for (const seed of opts.seeds) {
|
||||
if (!seed) continue;
|
||||
const rules = buildMatchRules(seed);
|
||||
for (const { rule, matchType } of rules) {
|
||||
const stdoutHit = findHit(stdout, rule);
|
||||
if (stdoutHit !== null) {
|
||||
leaks.push({ channel: 'stdout', matchType, excerpt: excerptAt(stdout, stdoutHit) });
|
||||
}
|
||||
const stderrHit = findHit(stderr, rule);
|
||||
if (stderrHit !== null) {
|
||||
leaks.push({ channel: 'stderr', matchType, excerpt: excerptAt(stderr, stderrHit) });
|
||||
}
|
||||
for (const [rel, content] of Object.entries(filesWritten)) {
|
||||
const hit = findHit(content, rule);
|
||||
if (hit !== null) {
|
||||
const channel = rel.startsWith('.gstack/analytics/') ? 'telemetry' : 'file';
|
||||
leaks.push({ channel, matchType, where: rel, excerpt: excerptAt(content, hit) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stdout, stderr, status, filesWritten, telemetry, leaks, tmpHome };
|
||||
}
|
||||
|
||||
function walk(root: string, dir: string, out: Record<string, string>) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isDirectory()) {
|
||||
walk(root, full, out);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) continue;
|
||||
if (stat.size > 1024 * 1024) continue; // skip huge files, unlikely to be secrets
|
||||
const rel = path.relative(root, full);
|
||||
try {
|
||||
out[rel] = fs.readFileSync(full, 'utf-8');
|
||||
} catch {
|
||||
// binary or unreadable — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMatchRules(seed: string): Array<{ rule: string; matchType: Leak['matchType'] }> {
|
||||
const rules: Array<{ rule: string; matchType: Leak['matchType'] }> = [];
|
||||
rules.push({ rule: seed, matchType: 'exact' });
|
||||
|
||||
// URL-decoded form — catches cases where the seed got percent-encoded
|
||||
// (e.g., a password with a '@' embedded in a connection string).
|
||||
try {
|
||||
const decoded = decodeURIComponent(seed);
|
||||
if (decoded !== seed) rules.push({ rule: decoded, matchType: 'url-decoded' });
|
||||
} catch {
|
||||
// malformed %-encoding in the seed itself; ignore
|
||||
}
|
||||
|
||||
// First-12-char prefix — catches partial leaks like "we logged the
|
||||
// first 10 chars for debugging." Only applied to seeds >= 16 chars,
|
||||
// since shorter seeds would false-positive against normal words.
|
||||
if (seed.length >= 16) {
|
||||
rules.push({ rule: seed.slice(0, 12), matchType: 'prefix-12' });
|
||||
}
|
||||
|
||||
// Base64 encoding — catches leaks through auth headers or config files
|
||||
// that encode the seed. Only for seeds >= 12 chars to reduce false
|
||||
// positives from short strings that happen to be valid base64.
|
||||
if (seed.length >= 12) {
|
||||
rules.push({ rule: Buffer.from(seed).toString('base64'), matchType: 'base64' });
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function findHit(haystack: string, needle: string): number | null {
|
||||
if (!needle) return null;
|
||||
const idx = haystack.indexOf(needle);
|
||||
return idx === -1 ? null : idx;
|
||||
}
|
||||
|
||||
function excerptAt(s: string, idx: number): string {
|
||||
const start = Math.max(0, idx - 20);
|
||||
const end = Math.min(s.length, idx + 40);
|
||||
return s.slice(start, end).replace(/\n/g, '\\n');
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'plan-devex-review-plan-mode': ['plan-devex-review/**', 'scripts/resolvers/preamble/generate-plan-mode-handshake.ts', 'scripts/resolvers/preamble.ts', 'scripts/question-registry.ts', 'scripts/one-way-doors.ts', 'test/helpers/agent-sdk-runner.ts'],
|
||||
'plan-mode-no-op': ['plan-ceo-review/**', 'scripts/resolvers/preamble/generate-plan-mode-handshake.ts', 'scripts/resolvers/preamble.ts', 'test/helpers/agent-sdk-runner.ts'],
|
||||
'e2e-harness-audit': ['plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/preamble/generate-plan-mode-handshake.ts', 'test/helpers/agent-sdk-runner.ts'],
|
||||
'brain-privacy-gate': ['scripts/resolvers/preamble/generate-brain-sync-block.ts', 'scripts/resolvers/preamble.ts', 'bin/gstack-brain-sync', 'bin/gstack-brain-init', 'bin/gstack-config', 'test/helpers/agent-sdk-runner.ts'],
|
||||
|
||||
// AskUserQuestion format regression (RECOMMENDATION + Completeness: N/10)
|
||||
// Fires when either template OR the two preamble resolvers change.
|
||||
@@ -336,6 +337,10 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic'> = {
|
||||
'plan-mode-no-op': 'gate',
|
||||
'e2e-harness-audit': 'gate',
|
||||
|
||||
// Privacy gate for gstack-brain-sync — periodic (non-deterministic LLM call,
|
||||
// costs ~$0.30-$0.50 per run, not needed on every commit)
|
||||
'brain-privacy-gate': 'periodic',
|
||||
|
||||
// AskUserQuestion format regression — periodic (Opus 4.7 non-deterministic benchmark)
|
||||
'plan-ceo-review-format-mode': 'periodic',
|
||||
'plan-ceo-review-format-approach': 'periodic',
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Tests for the secret-sink test harness (D21 #5).
|
||||
*
|
||||
* Positive controls: deliberately leak a seed in every covered channel and
|
||||
* assert the harness catches it. A harness that silently under-reports is
|
||||
* worse than no harness — these tests are the quality gate.
|
||||
*
|
||||
* Negative controls: run real setup-gbrain bins with known secrets; no
|
||||
* leaks should appear.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithSecretSink } from './helpers/secret-sink-harness';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const LEAK_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'leak-bins-'));
|
||||
|
||||
// Build a disposable bash script that leaks in a specific way. Returns
|
||||
// path to the executable. We don't bother cleaning these up per-test —
|
||||
// they live under a tmpdir that's fine to linger between tests.
|
||||
function makeLeakyBin(name: string, body: string): string {
|
||||
const p = path.join(LEAK_BIN_DIR, name);
|
||||
fs.writeFileSync(p, `#!/bin/bash\nset -euo pipefail\n${body}\n`, { mode: 0o755 });
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('secret-sink-harness — positive controls', () => {
|
||||
test('catches a seed echoed to stdout', async () => {
|
||||
const bin = makeLeakyBin(
|
||||
'leak-stdout',
|
||||
'echo "config contains: $LEAK_SEED"'
|
||||
);
|
||||
const seed = 'my-secret-password-12345';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
expect(r.leaks.length).toBeGreaterThan(0);
|
||||
const stdoutLeaks = r.leaks.filter((l) => l.channel === 'stdout');
|
||||
expect(stdoutLeaks.length).toBeGreaterThan(0);
|
||||
expect(stdoutLeaks.some((l) => l.matchType === 'exact')).toBe(true);
|
||||
});
|
||||
|
||||
test('catches a seed echoed to stderr', async () => {
|
||||
const bin = makeLeakyBin(
|
||||
'leak-stderr',
|
||||
'echo "leaked: $LEAK_SEED" >&2'
|
||||
);
|
||||
const seed = 'another-secret-value-67890';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
expect(r.leaks.some((l) => l.channel === 'stderr')).toBe(true);
|
||||
});
|
||||
|
||||
test('catches a seed written to a file under $HOME', async () => {
|
||||
const bin = makeLeakyBin(
|
||||
'leak-file',
|
||||
'mkdir -p "$HOME/.gstack" && echo "seed: $LEAK_SEED" > "$HOME/.gstack/debug.log"'
|
||||
);
|
||||
const seed = 'file-leaked-secret-value-xyz';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
const fileLeaks = r.leaks.filter((l) => l.channel === 'file');
|
||||
expect(fileLeaks.length).toBeGreaterThan(0);
|
||||
expect(fileLeaks[0].where).toBe('.gstack/debug.log');
|
||||
});
|
||||
|
||||
test('catches a seed leaked into the telemetry channel', async () => {
|
||||
const bin = makeLeakyBin(
|
||||
'leak-telemetry',
|
||||
'mkdir -p "$HOME/.gstack/analytics" && ' +
|
||||
'echo "{\\"event\\":\\"x\\",\\"leaked_secret\\":\\"$LEAK_SEED\\"}" ' +
|
||||
' >> "$HOME/.gstack/analytics/skill-usage.jsonl"'
|
||||
);
|
||||
const seed = 'telemetry-leaked-abc123xyz';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
const telemetryLeaks = r.leaks.filter((l) => l.channel === 'telemetry');
|
||||
expect(telemetryLeaks.length).toBeGreaterThan(0);
|
||||
expect(telemetryLeaks[0].where).toContain('analytics/');
|
||||
});
|
||||
|
||||
test('catches a seed leaked in base64-encoded form (auth header pattern)', async () => {
|
||||
// printf (not echo) so no trailing newline — matches how real auth
|
||||
// headers encode: base64(seed) exactly, not base64(seed + "\n").
|
||||
const bin = makeLeakyBin(
|
||||
'leak-base64',
|
||||
'printf "%s" "$LEAK_SEED" | base64'
|
||||
);
|
||||
const seed = 'base64-leaked-long-enough-secret';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
expect(r.leaks.some((l) => l.matchType === 'base64')).toBe(true);
|
||||
});
|
||||
|
||||
test('catches a first-12-char prefix leak (the "I only logged a portion" pattern)', async () => {
|
||||
const bin = makeLeakyBin(
|
||||
'leak-prefix',
|
||||
'prefix="${LEAK_SEED:0:12}"; echo "debug prefix: $prefix"'
|
||||
);
|
||||
const seed = 'prefix-leaked-0123456789abcdef';
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
env: { LEAK_SEED: seed },
|
||||
});
|
||||
expect(r.leaks.some((l) => l.matchType === 'prefix-12')).toBe(true);
|
||||
});
|
||||
|
||||
test('clean run with no leak returns an empty leaks array', async () => {
|
||||
const bin = makeLeakyBin('clean', 'echo "no secret here"');
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: ['never-emitted-seed-xyz-987'],
|
||||
});
|
||||
expect(r.leaks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret-sink-harness — real bins (negative controls)', () => {
|
||||
test('supabase-verify does not leak a URL password on reject', async () => {
|
||||
const bin = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-verify');
|
||||
const seedPassword = 'extremely-distinctive-password-abc-xyz-987';
|
||||
// Use a URL that will be REJECTED (wrong scheme) so all error paths run
|
||||
const leakyUrl = `mysql://user:${seedPassword}@host:6543/db`;
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [leakyUrl],
|
||||
seeds: [seedPassword],
|
||||
});
|
||||
// Status 2 — rejected as expected
|
||||
expect(r.status).toBe(2);
|
||||
// No leaks in any channel
|
||||
expect(r.leaks).toEqual([]);
|
||||
});
|
||||
|
||||
test('supabase-verify does not leak on direct-connection rejection path', async () => {
|
||||
const bin = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-verify');
|
||||
const seedPassword = 'another-distinctive-secret-for-direct-conn';
|
||||
const leakyUrl = `postgresql://postgres:${seedPassword}@db.abcdef.supabase.co:5432/postgres`;
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [leakyUrl],
|
||||
seeds: [seedPassword],
|
||||
});
|
||||
expect(r.status).toBe(3);
|
||||
expect(r.leaks).toEqual([]);
|
||||
});
|
||||
|
||||
test('lib.sh read_secret_to_env does not leak stdin via captured channels', async () => {
|
||||
const seed = 'piped-secret-that-should-stay-invisible-zzz';
|
||||
// Wrapper script: source lib.sh, read secret, echo only its length.
|
||||
const lib = path.join(ROOT, 'bin', 'gstack-gbrain-lib.sh');
|
||||
const bin = makeLeakyBin(
|
||||
'read-secret-wrapper',
|
||||
`. "${lib}"\nread_secret_to_env MY_SECRET "Prompt: "\necho "len=\${#MY_SECRET}"`
|
||||
);
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: [],
|
||||
seeds: [seed],
|
||||
stdin: seed,
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
// The length is visible (43) but the value is not
|
||||
expect(r.stdout).toContain(`len=${seed.length}`);
|
||||
expect(r.leaks).toEqual([]);
|
||||
});
|
||||
|
||||
test('supabase-provision does not leak a PAT on auth-failure path', async () => {
|
||||
const bin = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-provision');
|
||||
const seedPat = 'sbp_very_distinctive_pat_seed_abc_xyz_1234567890';
|
||||
// With no SUPABASE_API_BASE override, the bin tries the real API URL.
|
||||
// We want to avoid real network calls — point at a bogus URL that
|
||||
// immediately fails with curl. The bin should exit with an error
|
||||
// WITHOUT leaking the PAT to any channel.
|
||||
const r = await runWithSecretSink({
|
||||
bin,
|
||||
args: ['list-orgs'],
|
||||
seeds: [seedPat],
|
||||
env: {
|
||||
SUPABASE_ACCESS_TOKEN: seedPat,
|
||||
// Nonexistent port — curl fails fast.
|
||||
SUPABASE_API_BASE: 'http://127.0.0.1:1',
|
||||
},
|
||||
timeoutMs: 30_000, // curl retries with backoff — give it room to exit
|
||||
});
|
||||
// Expect a non-zero exit (network failure, exit 8 per the bin's
|
||||
// retry-exhausted path)
|
||||
expect(r.status).not.toBe(0);
|
||||
expect(r.leaks).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Privacy-gate E2E (periodic tier, paid).
|
||||
*
|
||||
* The gbrain-sync preamble block instructs the model to fire a one-time
|
||||
* AskUserQuestion when:
|
||||
* - `BRAIN_SYNC: off` in the preamble echo (sync mode not on)
|
||||
* - config `gbrain_sync_mode_prompted` is "false"
|
||||
* - gbrain is detected on the host (binary on PATH or `gbrain doctor`
|
||||
* --fast --json succeeds)
|
||||
*
|
||||
* This test stages all three conditions (via env + a fake `gbrain` binary
|
||||
* on PATH), runs a cheap gstack skill through the Agent SDK, intercepts
|
||||
* every tool use via canUseTool, and asserts: one of the AskUserQuestions
|
||||
* fired by the preamble is the privacy gate with its distinctive prose
|
||||
* and three options (full / artifacts-only / decline).
|
||||
*
|
||||
* Cost: ~$0.30-$0.50 per run. Periodic tier (EVALS=1 EVALS_TIER=periodic).
|
||||
*
|
||||
* See scripts/resolvers/preamble/generate-brain-sync-block.ts for the
|
||||
* prose contract this test locks in.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runAgentSdkTest, passThroughNonAskUserQuestion, resolveClaudeBinary } from './helpers/agent-sdk-runner';
|
||||
|
||||
const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';
|
||||
const describeE2E = shouldRun ? describe : describe.skip;
|
||||
|
||||
describeE2E('gbrain-sync privacy gate fires once via preamble', () => {
|
||||
test('gstack skill preamble fires the 3-option AskUserQuestion when gbrain is detected', async () => {
|
||||
// Stage a fresh GSTACK_HOME with gbrain_sync_mode_prompted=false.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-gstack-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-bin-'));
|
||||
|
||||
// Seed the config so the gate's condition passes.
|
||||
fs.writeFileSync(
|
||||
path.join(gstackHome, 'config.yaml'),
|
||||
'gbrain_sync_mode: off\ngbrain_sync_mode_prompted: false\n',
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
// Fake `gbrain` binary that makes the host-detection probe succeed.
|
||||
// The preamble checks `gbrain doctor --fast --json` OR `which gbrain`.
|
||||
// Either branch counts as "gbrain detected."
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'gbrain'),
|
||||
'#!/bin/bash\n' +
|
||||
'case "$1" in\n' +
|
||||
' doctor) echo \'{"status":"ok","schema_version":2}\' ; exit 0 ;;\n' +
|
||||
' --version) echo "0.18.2" ; exit 0 ;;\n' +
|
||||
' *) exit 0 ;;\n' +
|
||||
'esac\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations — restored in finally so other tests in the file
|
||||
// don't inherit them.
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
|
||||
try {
|
||||
// Pick a small skill with the preamble and load it via Read to force
|
||||
// the model to execute every preamble directive. A narrow "run /learn"
|
||||
// prompt often gets reduced to a direct action, skipping the preamble
|
||||
// gates. Mirror the plan-mode-no-op test pattern: ask the model to
|
||||
// follow the skill's instructions in full.
|
||||
const learnSkill = path.resolve(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'learn',
|
||||
'SKILL.md'
|
||||
);
|
||||
await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
userPrompt:
|
||||
`Read the skill file at ${learnSkill} and follow its instructions from the top, including every preamble directive. Execute every bash block. If any AskUserQuestion fires, present it.`,
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
// NOTE: do NOT pass `env:` here. When the Agent SDK gets an explicit
|
||||
// env object, its auth pipeline doesn't pick up ANTHROPIC_API_KEY the
|
||||
// same way as when env is undefined (SDK-internal detail, verified
|
||||
// against the plan-mode-no-op test which passes no env and auths
|
||||
// cleanly). Instead, mutate process.env before the call so the SDK
|
||||
// inherits our overrides ambiently.
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
askUserQuestions.push({ input });
|
||||
// Auto-answer "Decline — keep everything local" (option C)
|
||||
// so the skill can continue without actually turning on sync.
|
||||
const q = (input.questions as Array<{
|
||||
question: string;
|
||||
options: Array<{ label: string }>;
|
||||
}>)[0];
|
||||
const decline =
|
||||
q.options.find((o) => /decline|keep everything local|no thanks/i.test(o.label)) ??
|
||||
q.options[q.options.length - 1]!;
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: {
|
||||
questions: input.questions,
|
||||
answers: { [q.question]: decline.label },
|
||||
},
|
||||
};
|
||||
}
|
||||
return passThroughNonAskUserQuestion(toolName, input);
|
||||
},
|
||||
});
|
||||
|
||||
// Assertion 1: the privacy gate fired.
|
||||
const privacyQuestions = askUserQuestions.filter((aq) => {
|
||||
const qs = aq.input.questions as Array<{ question: string }>;
|
||||
return qs.some(
|
||||
(q) =>
|
||||
/publish.*session memory|private github repo|gbrain indexes/i.test(q.question)
|
||||
);
|
||||
});
|
||||
expect(privacyQuestions.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assertion 2: the question has the three expected options.
|
||||
const gate = privacyQuestions[0]!.input.questions as Array<{
|
||||
question: string;
|
||||
options: Array<{ label: string }>;
|
||||
}>;
|
||||
const labels = gate[0]!.options.map((o) => o.label.toLowerCase()).join(' | ');
|
||||
// Full / artifacts-only / decline are the three canonical options.
|
||||
expect(labels).toMatch(/everything|allowlisted|full/);
|
||||
expect(labels).toMatch(/artifact/);
|
||||
expect(labels).toMatch(/decline|local|no thanks/);
|
||||
|
||||
// Assertion 3: the gate should NOT fire twice in one run.
|
||||
// (The preamble is supposed to be idempotent within a session.)
|
||||
expect(privacyQuestions.length).toBe(1);
|
||||
} finally {
|
||||
// Restore ambient env before other tests.
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
test('privacy gate does NOT fire when gbrain_sync_mode_prompted is already true', async () => {
|
||||
// Same staging, but prompted=true this time. Gate should be silent.
|
||||
const gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-'));
|
||||
const fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-off-bin-'));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(gstackHome, 'config.yaml'),
|
||||
'gbrain_sync_mode: off\ngbrain_sync_mode_prompted: true\n',
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'gbrain'),
|
||||
'#!/bin/bash\necho \'{"status":"ok"}\'\nexit 0\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
const askUserQuestions: Array<{ input: Record<string, unknown> }> = [];
|
||||
const binary = resolveClaudeBinary();
|
||||
|
||||
// Ambient env mutations (see note on the first test).
|
||||
const origGstackHome = process.env.GSTACK_HOME;
|
||||
const origPath = process.env.PATH;
|
||||
process.env.GSTACK_HOME = gstackHome;
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH ?? '/usr/bin:/bin:/opt/homebrew/bin'}`;
|
||||
|
||||
try {
|
||||
await runAgentSdkTest({
|
||||
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
||||
userPrompt:
|
||||
'Run /learn with no arguments. Just report the learnings count.',
|
||||
workingDirectory: gstackHome,
|
||||
maxTurns: 4,
|
||||
allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
|
||||
...(binary ? { pathToClaudeCodeExecutable: binary } : {}),
|
||||
canUseTool: async (toolName, input) => {
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
askUserQuestions.push({ input });
|
||||
// Pass through whatever the model asks; don't prefer anything.
|
||||
const q = (input.questions as Array<{
|
||||
question: string;
|
||||
options: Array<{ label: string }>;
|
||||
}>)[0];
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: {
|
||||
questions: input.questions,
|
||||
answers: { [q.question]: q.options[0]!.label },
|
||||
},
|
||||
};
|
||||
}
|
||||
return passThroughNonAskUserQuestion(toolName, input);
|
||||
},
|
||||
});
|
||||
|
||||
// No AskUserQuestion should have matched the privacy gate's prose.
|
||||
const privacyQuestions = askUserQuestions.filter((aq) => {
|
||||
const qs = aq.input.questions as Array<{ question: string }>;
|
||||
return qs.some(
|
||||
(q) =>
|
||||
/publish.*session memory|private github repo|gbrain indexes/i.test(q.question)
|
||||
);
|
||||
});
|
||||
expect(privacyQuestions.length).toBe(0);
|
||||
} finally {
|
||||
if (origGstackHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = origGstackHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = origPath;
|
||||
fs.rmSync(gstackHome, { recursive: true, force: true });
|
||||
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
Reference in New Issue
Block a user