mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 11:17:50 +02:00
2014557e7f
* 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>
258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|