From 43c815be88ddd6351d08b402ac486c13c45267bb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 14 Nov 2026 01:29:07 +0800 Subject: [PATCH] The gbrain source wireup and skill start follow GBRAIN_HOME, so the wireup writes to the right brain (GST-2621) (#4343) * Find gbrain's config under GBRAIN_HOME in the source wireup and skill start (#4325) Both helpers read only ~/.gbrain/config.json. With GBRAIN_HOME naming one brain and ~/.gbrain holding another, the wireup locked the ~/.gbrain brain's database_url and sent sources add and sync there, and skill start skipped its /sync-gbrain hint. Both now resolve the path through one shared helper, bin/gstack-gbrain-home.sh, using gbrain's configDir() rule. The wireup gains a read-only --check-target mode that tells an operator whether an earlier run could have written to the wrong brain, without printing any URL. Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Use localhost test database URLs the credential scan accepts (#4325) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Test the shared gbrain config path helper directly (#4325) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Report the caller's database URL settings in the wireup target check (#4325) --check-target printed the URL env after the startup lock had exported GBRAIN_DATABASE_URL, so it said a URL was set when the caller had none. The helper comment no longer calls gbrainConfigDir() an exact twin. Review-Finding: p1/specialist-batched#1 reader=red-team Review-Finding: p1/specialist-batched#2 reader=testing Review-Finding: p1/specialist-batched#3 reader=testing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Document the GBRAIN_HOME config path and the wireup target check (#4325) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- USING_GBRAIN_WITH_GSTACK.md | 5 +- bin/gstack-gbrain-home.sh | 35 +++++ bin/gstack-gbrain-source-wireup | 91 +++++++++-- bin/gstack-skill-start | 14 +- changelog.d/fix-gbrain-home-helpers.md | 4 + test/gstack-gbrain-source-wireup.test.ts | 183 +++++++++++++++++++++++ test/gstack-skill-start.test.ts | 24 +++ 7 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 bin/gstack-gbrain-home.sh create mode 100644 changelog.d/fix-gbrain-home-helpers.md diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md index 60f60c48e..d4a59d370 100644 --- a/USING_GBRAIN_WITH_GSTACK.md +++ b/USING_GBRAIN_WITH_GSTACK.md @@ -218,7 +218,8 @@ The skill re-collects a PAT (one-time, discarded after), lists every project in | `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` | -| `gstack-gbrain-source-wireup` | Registers your `~/.gstack/` brain repo with gbrain as a federated source via `gbrain sources add` + `git worktree`, then runs an initial `gbrain sync`. Idempotent. Replaces the dead `consumers.json + /ingest-repo` HTTP wireup from v1.12.x. Flags: `--strict`, `--source-id `, `--no-pull`, `--uninstall`, `--probe`. | +| `gstack-gbrain-source-wireup` | Registers your `~/.gstack/` brain repo with gbrain as a federated source via `gbrain sources add` + `git worktree`, then runs an initial `gbrain sync`. Idempotent. Replaces the dead `consumers.json + /ingest-repo` HTTP wireup from v1.12.x. Flags: `--strict`, `--source-id `, `--no-pull`, `--uninstall`, `--probe`, `--advance-only`, and `--check-target` (read-only: reports `TARGET: MATCH` or `TARGET: MISMATCH` for whether an earlier run with `GBRAIN_HOME` set could have used the wrong brain; never prints a database URL). | +| `gstack-gbrain-home.sh` | Sourced, not executed. Provides `gstack_gbrain_config_path`, which prints the config.json gbrain itself reads (`$GBRAIN_HOME/.gbrain/config.json`, else `~/.gbrain/config.json`) and prints nothing for a `GBRAIN_HOME` gbrain refuses (relative, or with a `..` segment). Used by the source wireup and skill start. | ### gbrain CLI (upstream tool) @@ -240,7 +241,7 @@ Gbrain itself ships with these that gstack wraps: | Path | What lives there | |---|---| -| `~/.gbrain/config.json` | Engine (pglite/postgres), database URL or path, API keys. Mode 0600. Written by `gbrain init`. | +| `~/.gbrain/config.json` | Engine (pglite/postgres), database URL or path, API keys. Mode 0600. Written by `gbrain init`. When `GBRAIN_HOME` is set, gbrain and gstack's helpers read `$GBRAIN_HOME/.gbrain/config.json` instead. | | `~/.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.d/` | Pending sync records for gstack memory sync — maildir-style spool, one file per record. A legacy `.brain-queue.jsonl` from older releases migrates automatically on the next drain. | diff --git a/bin/gstack-gbrain-home.sh b/bin/gstack-gbrain-home.sh new file mode 100644 index 000000000..cc174e5c4 --- /dev/null +++ b/bin/gstack-gbrain-home.sh @@ -0,0 +1,35 @@ +# gstack-gbrain-home.sh — find the config.json gbrain itself reads. +# +# This file is NOT executable; source it: +# +# . "$(dirname "$0")/gstack-gbrain-home.sh" +# +# gstack_gbrain_config_path prints the path of gbrain's config.json using +# gbrain's own configDir() rule (src/core/config.ts in garrytan/gbrain): +# GBRAIN_HOME, trimmed of surrounding whitespace, is the PARENT directory and +# ".gbrain" is appended; unset or blank means $HOME/.gbrain. gbrain refuses a +# GBRAIN_HOME that is not absolute or that has a ".." segment, so for one of +# those this prints nothing and returns 1, and the caller must not fall back to +# $HOME/.gbrain (that would be a brain gbrain never reads). gbrainConfigDir() in +# lib/gbrain-exec.ts applies the same parent-directory rule for TypeScript +# callers but does not yet trim or refuse. Every gstack shell read of gbrain's +# config goes through here (#4325). +gstack_gbrain_config_path() { + local home="${GBRAIN_HOME:-}" + home="${home#"${home%%[![:space:]]*}"}" + home="${home%"${home##*[![:space:]]}"}" + if [ -z "$home" ]; then + printf '%s\n' "$HOME/.gbrain/config.json" + return 0 + fi + case "$home" in + /*) ;; + [A-Za-z]:[\\/]*|\\*) + case "${OSTYPE:-}" in msys*|cygwin*) ;; *) return 1 ;; esac ;; + *) return 1 ;; + esac + case "/$home/" in + */../*|*\\..\\*|*/..\\*|*\\../*) return 1 ;; + esac + printf '%s\n' "${home%/}/.gbrain/config.json" +} diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 97cbb1b4a..fbaa7fb88 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -13,6 +13,7 @@ # [--database-url ] # gstack-gbrain-source-wireup --probe # gstack-gbrain-source-wireup --advance-only # daily unattended worktree advance (#2516) +# gstack-gbrain-source-wireup --check-target # read-only: could an earlier run have hit the wrong brain? (#4325) # gstack-gbrain-source-wireup --help # # Exit codes: @@ -28,9 +29,10 @@ # GSTACK_BRAIN_NO_SYNC — skip the gbrain sync step (tests; helper still # ensures source registration) # -# Defense against external rewrites of ~/.gbrain/config.json: +# Defense against external rewrites of gbrain's config.json: # At helper startup we capture the database URL ONCE — from --database-url, -# from GBRAIN_DATABASE_URL/DATABASE_URL env, or from ~/.gbrain/config.json — +# from GBRAIN_DATABASE_URL/DATABASE_URL env, or from the config.json gbrain +# itself reads ($GBRAIN_HOME/.gbrain, else ~/.gbrain; gstack-gbrain-home.sh) — # and export it as GBRAIN_DATABASE_URL for every child `gbrain` invocation. # That env var overrides whatever's in config.json (per gbrain's loadConfig # at src/core/config.ts:53), so a process that flips config.json mid-sync @@ -55,7 +57,9 @@ else REMOTE_FILE="$HOME/.gstack-brain-remote.txt" fi PLIST_PATH="$HOME/Library/LaunchAgents/com.gstack.brain-sync.plist" -GBRAIN_CONFIG="$HOME/.gbrain/config.json" +. "$SCRIPT_DIR/gstack-gbrain-home.sh" +GBRAIN_HOME_VALID=1 +GBRAIN_CONFIG=$(gstack_gbrain_config_path) || GBRAIN_HOME_VALID=0 # Git writes to $GSTACK_HOME (worktree add/prune/remove, the daily advance) # take the drain lock every gstack writer shares. A caller that already holds @@ -80,20 +84,33 @@ while [ $# -gt 0 ]; do --uninstall) MODE="uninstall"; shift ;; --probe) MODE="probe"; shift ;; --advance-only) MODE="advance-only"; shift ;; + --check-target) MODE="check-target"; shift ;; --strict) STRICT=1; shift ;; --no-pull) NO_PULL=1; shift ;; --source-id) SOURCE_ID="$2"; shift 2 ;; --database-url) DATABASE_URL_ARG="$2"; shift 2 ;; - --help|-h) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --help|-h) sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown flag: $1" >&2; exit 1 ;; esac done +prefix() { sed 's/^/gstack-gbrain-source-wireup: /' >&2; } +warn() { echo "$*" | prefix; } +# die [exit_code]: warn with just the message, exit with code (default 1). +die() { warn "$1"; exit "${2:-1}"; } + # ---- lock the database URL at startup ---- # Precedence: --database-url flag > existing GBRAIN_DATABASE_URL/DATABASE_URL -# env > read once from ~/.gbrain/config.json. Whichever wins gets exported as -# GBRAIN_DATABASE_URL so every child `gbrain` invocation uses THAT brain even -# if config.json is rewritten by another process during the wireup. +# env > read once from the config.json gbrain itself reads (GBRAIN_CONFIG +# above; #4325). Whichever wins gets exported as GBRAIN_DATABASE_URL so every +# child `gbrain` invocation uses THAT brain even if config.json is rewritten +# by another process during the wireup. A GBRAIN_HOME that gbrain refuses +# locks nothing: falling back to ~/.gbrain would pick a brain gbrain never reads. +if [ "$GBRAIN_HOME_VALID" = "0" ]; then + warn "GBRAIN_HOME must be an absolute path with no '..' segments (gbrain refuses this one); not reading any gbrain config" +fi +# --check-target reports the caller's URL env, before the lock below sets it. +_URL_ENV="GBRAIN_DATABASE_URL $([ -n "${GBRAIN_DATABASE_URL:-}" ] && echo set || echo unset), DATABASE_URL $([ -n "${DATABASE_URL:-}" ] && echo set || echo unset)" _locked_url="" if [ -n "$DATABASE_URL_ARG" ]; then _locked_url="$DATABASE_URL_ARG" @@ -101,7 +118,7 @@ elif [ -n "${GBRAIN_DATABASE_URL:-}" ]; then _locked_url="$GBRAIN_DATABASE_URL" elif [ -n "${DATABASE_URL:-}" ]; then _locked_url="$DATABASE_URL" -elif [ -f "$GBRAIN_CONFIG" ]; then +elif [ -n "$GBRAIN_CONFIG" ] && [ -f "$GBRAIN_CONFIG" ]; then # Python heredoc reads config.json. On JSON parse failure or any IO error, # we WARN (not silently swallow) so the user knows the URL lock fell back # to gbrain's own loadConfig (which would still read this same file). @@ -123,10 +140,6 @@ if [ -n "$_locked_url" ]; then export GBRAIN_DATABASE_URL="$_locked_url" fi -prefix() { sed 's/^/gstack-gbrain-source-wireup: /' >&2; } -warn() { echo "$*" | prefix; } -# die [exit_code]: warn with just the message, exit with code (default 1). -die() { warn "$1"; exit "${2:-1}"; } # Refuse to rm anything outside $HOME/. Defends against GSTACK_BRAIN_WORKTREE=/ # or empty-string overrides that would otherwise have line 169 / 161 nuke the @@ -452,9 +465,63 @@ do_uninstall() { echo "uninstalled source=$id worktree=$WORKTREE" } +do_check_target() { + # Read-only audit for #4325. Before the fix, a run with GBRAIN_HOME set and no + # database URL in its environment locked ~/.gbrain/config.json's database_url, + # so `sources add` and `sync` went to that brain instead of the one under + # GBRAIN_HOME. This compares the two configs and prints MATCH or MISMATCH. + # It calls no gbrain and no network, writes nothing, and never prints a + # database URL or any part of one: the URLs stay inside python3. + local id state=set + id=$(derive_source_id 2>/dev/null) || id="(unknown)" + if [ "$GBRAIN_HOME_VALID" = "0" ]; then + state=invalid + elif [ "$GBRAIN_CONFIG" = "$HOME/.gbrain/config.json" ] && [ -z "$(printf '%s' "${GBRAIN_HOME:-}" | tr -d '[:space:]')" ]; then + state=unset + fi + echo "SOURCE_ID: $id" + echo "URL_ENV: $_URL_ENV (a run with either set used that URL before and after the fix)" + CT_STATE="$state" CT_DEFAULT="$HOME/.gbrain/config.json" CT_NAMED="$GBRAIN_CONFIG" python3 -c ' +import json, os + +def read(p): + try: + with open(p) as f: + c = json.load(f) + except FileNotFoundError: + return "no file", None + except Exception: + return "unreadable", None + u = c.get("database_url") if isinstance(c, dict) else None + return ("database_url set", u) if isinstance(u, str) and u else ("no database_url", None) + +state, default, named = os.environ["CT_STATE"], os.environ["CT_DEFAULT"], os.environ["CT_NAMED"] +d_status, d_url = read(default) +print(f"GBRAIN_HOME: {state}") +print(f"DEFAULT_CONFIG: {default} ({d_status})") +if state == "set": + n_status, n_url = read(named) + print(f"GBRAIN_HOME_CONFIG: {named} ({n_status})") +if state == "unset": + verdict = "MATCH GBRAIN_HOME is unset, so the old and new rules read the same config" +elif state == "invalid": + verdict = "UNKNOWN gbrain refuses this GBRAIN_HOME (it must be absolute with no .. segments); check the value your wrapper sets" +elif d_status == "unreadable": + verdict = "UNKNOWN the default config is unreadable, so the old rule locked nothing it could report" +elif d_url is None or os.path.realpath(named) == os.path.realpath(default): + verdict = "MATCH the old rule locked nothing extra, so gbrain used the GBRAIN_HOME config itself" +elif n_url == d_url: + verdict = "MATCH both configs name the same database" +else: + verdict = "MISMATCH a run with GBRAIN_HOME set and no URL in its environment registered and synced this source in the brain named by DEFAULT_CONFIG, not GBRAIN_HOME_CONFIG; to confirm, run gbrain sources list with GBRAIN_HOME unset and look for SOURCE_ID" +print(f"TARGET: {verdict}") +' /dev/null 2>&1; then +# configured (zero context cost for non-gbrain users). gbrain's config path +# comes from gstack-gbrain-home.sh, which follows GBRAIN_HOME as gbrain does +# (#4325); empty when that file is missing or gbrain would refuse GBRAIN_HOME. +_GBRAIN_CONFIG=$( . "$_BIN/gstack-gbrain-home.sh" 2>/dev/null && gstack_gbrain_config_path ) || _GBRAIN_CONFIG="" +if [ -n "$_GBRAIN_CONFIG" ] && [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then _GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || true) if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then _GBRAIN_PIN_PATH="" @@ -442,12 +444,12 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr # matches the TS one: remote_mcp is a marker when JS-truthy, as in # gbrain's isThinClient (null, false, 0 and "" are not), and # database_url counts when non-blank after JS trim() and engine is not - # pglite. GBRAIN_HOME is the - # parent of .gbrain, as in gbrainConfigDir. test/gbrain-mcp-locality.test.ts + # pglite. The config is _GBRAIN_CONFIG above (GBRAIN_HOME is the + # parent of .gbrain, as in gbrainConfigDir). test/gbrain-mcp-locality.test.ts # runs both programs below against the TS rule. if printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -e '(.url // "") | ascii_downcase | (contains("${") | not) and test("\\Ahttps?://(localhost|127(\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|\\[::1\\])(:([0-9]{0,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?([/?#]|\\z)")' >/dev/null 2>&1 \ && jq -e '((.remote_mcp // false) | . == false or . == 0 or . == "") and .engine != "pglite" and ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}")))' \ - "${GBRAIN_HOME:-$HOME}/.gbrain/config.json" >/dev/null 2>&1; then + "$_GBRAIN_CONFIG" >/dev/null 2>&1; then _GBRAIN_MCP_MODE="local-stdio" fi ;; diff --git a/changelog.d/fix-gbrain-home-helpers.md b/changelog.d/fix-gbrain-home-helpers.md new file mode 100644 index 000000000..8d92d2d04 --- /dev/null +++ b/changelog.d/fix-gbrain-home-helpers.md @@ -0,0 +1,4 @@ +--- +important: data-loss +--- +- **The gbrain source wireup and skill start now find gbrain's config under `GBRAIN_HOME`, so the wireup no longer writes to the wrong brain.** Both helpers read only `~/.gbrain/config.json` (#4325). On a machine with two brains, where `GBRAIN_HOME` names one and `~/.gbrain` holds another, a wireup run with no database URL in its environment (the `/setup-gbrain` path, `gstack-brain-restore`, the daily advance) locked the `~/.gbrain` brain's `database_url` and sent `sources add` and `sync` there, and skill start never printed its `/sync-gbrain` hint when the only config was under `GBRAIN_HOME`. Both now resolve the path the way gbrain does, through one shared helper (`bin/gstack-gbrain-home.sh`): `GBRAIN_HOME` trimmed, with `.gbrain` appended, else `~/.gbrain`; a `GBRAIN_HOME` gbrain would refuse (relative, or with a `..` segment) locks nothing instead of falling back to `~/.gbrain`. To learn whether an earlier run could have hit the wrong brain, run `gstack-gbrain-source-wireup --check-target` with the same environment your wrapper sets. It prints `TARGET: MATCH` or `TARGET: MISMATCH`, reads the two configs only, and never prints a database URL. diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts index c792ff196..c969247ae 100644 --- a/test/gstack-gbrain-source-wireup.test.ts +++ b/test/gstack-gbrain-source-wireup.test.ts @@ -606,3 +606,186 @@ describe('gstack-gbrain-source-wireup without jq (B8)', () => { }); }); + +// #4325: the lock and every database call must follow GBRAIN_HOME the way +// gbrain's configDir() does. Two homes hold different database_urls; the +// GBRAIN_HOME one is the brain gbrain itself would use. +describe('gstack-gbrain-source-wireup — GBRAIN_HOME (#4325)', () => { + const DEFAULT_URL = 'postgresql://postgres:postgres@localhost:5432/default_brain'; + const NAMED_URL = 'postgresql://postgres:postgres@localhost:5433/named_brain'; + let gbrainHome: string; + + function writeConfig(dir: string, config: Record) { + fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.gbrain', 'config.json'), JSON.stringify(config)); + } + + function databaseCalls(): string[] { + return gbrainCalls().filter((c) => c.includes('sources add') || /^gbrain sync --source/.test(c)); + } + + beforeEach(() => { + gbrainHome = path.join(tmpHome, 'named-home'); + writeConfig(tmpHome, { engine: 'postgres', database_url: DEFAULT_URL }); + writeConfig(gbrainHome, { engine: 'postgres', database_url: NAMED_URL }); + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + }); + + test('GBRAIN_HOME set: sources add and sync carry the GBRAIN_HOME brain, never the default one', () => { + const r = run([], { env: { GBRAIN_DATABASE_URL: '', DATABASE_URL: '', GBRAIN_HOME: gbrainHome } }); + expect(r.status).toBe(0); + const calls = databaseCalls(); + expect(calls.length).toBe(2); + for (const c of calls) { + expect(c).toContain(`[GBRAIN_DATABASE_URL=${NAMED_URL}]`); + expect(c).not.toContain(DEFAULT_URL); + } + }); + + test('negative control: GBRAIN_HOME unset still locks the default brain', () => { + const r = run([], { env: { GBRAIN_DATABASE_URL: '', DATABASE_URL: '' } }); + expect(r.status).toBe(0); + const calls = databaseCalls(); + expect(calls.length).toBe(2); + for (const c of calls) expect(c).toContain(`[GBRAIN_DATABASE_URL=${DEFAULT_URL}]`); + }); + + test('GBRAIN_HOME with surrounding whitespace resolves as gbrain trims it', () => { + const r = run([], { + env: { GSTACK_BRAIN_NO_SYNC: '1', GBRAIN_DATABASE_URL: '', DATABASE_URL: '', GBRAIN_HOME: ` ${gbrainHome} ` }, + }); + expect(r.status).toBe(0); + expect(databaseCalls()[0]).toContain(`[GBRAIN_DATABASE_URL=${NAMED_URL}]`); + }); + + test('GBRAIN_HOME config without a database_url exports nothing, so gbrain picks its own brain', () => { + writeConfig(gbrainHome, { engine: 'pglite' }); + const r = run([], { + env: { GSTACK_BRAIN_NO_SYNC: '1', GBRAIN_DATABASE_URL: '', DATABASE_URL: '', GBRAIN_HOME: gbrainHome }, + }); + expect(r.status).toBe(0); + const calls = databaseCalls(); + expect(calls.length).toBe(1); + expect(calls[0]).not.toContain('GBRAIN_DATABASE_URL='); + }); + + test('a relative GBRAIN_HOME (gbrain refuses it) never falls back to the default brain', () => { + const r = run([], { + env: { GSTACK_BRAIN_NO_SYNC: '1', GBRAIN_DATABASE_URL: '', DATABASE_URL: '', GBRAIN_HOME: 'relative/home' }, + }); + expect(r.stderr).toContain('GBRAIN_HOME'); + for (const c of gbrainCalls()) expect(c).not.toContain(DEFAULT_URL); + }); + + test('the ZeroEntropy advisory reads the GBRAIN_HOME config', () => { + writeConfig(gbrainHome, { engine: 'postgres', database_url: NAMED_URL, embedding: { recipe: 'zeroentropyai' } }); + const r = run([], { + env: { GSTACK_BRAIN_NO_SYNC: '1', GBRAIN_DATABASE_URL: '', DATABASE_URL: '', GBRAIN_HOME: gbrainHome }, + }); + expect(r.status).toBe(0); + expect(r.stderr).toContain('ZeroEntropy'); + }); +}); + +// #4325 audit: --check-target tells an operator whether an earlier wireup run +// with GBRAIN_HOME set could have written to the default brain. Read-only, and +// it never prints a database URL or any part of one. +describe('gstack-gbrain-source-wireup --check-target (#4325)', () => { + const DEFAULT_URL = 'postgresql://postgres:postgres@localhost:5432/default_brain'; + const NAMED_URL = 'postgresql://postgres:postgres@localhost:5433/named_brain'; + let gbrainHome: string; + + function writeConfig(dir: string, config: Record) { + fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.gbrain', 'config.json'), JSON.stringify(config)); + } + + function check(env: Record) { + const r = run(['--check-target'], { env: { GBRAIN_DATABASE_URL: '', DATABASE_URL: '', ...env } }); + const out = r.stdout + r.stderr; + for (const secret of [DEFAULT_URL, NAMED_URL, 'postgres:postgres', 'localhost', ':5432', ':5433', 'default_brain', 'named_brain', 'postgresql://']) { + expect(out).not.toContain(secret); + } + expect(gbrainCalls()).toEqual([]); + return r; + } + + beforeEach(() => { + gbrainHome = path.join(tmpHome, 'named-home'); + writeConfig(tmpHome, { engine: 'postgres', database_url: DEFAULT_URL }); + writeConfig(gbrainHome, { engine: 'postgres', database_url: NAMED_URL }); + makeFakeGbrain({}); + }); + + test('two brains with different URLs under GBRAIN_HOME → MISMATCH', () => { + const r = check({ GBRAIN_HOME: gbrainHome }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^TARGET: MISMATCH /m); + expect(r.stdout).toMatch(/^URL_ENV: GBRAIN_DATABASE_URL unset, DATABASE_URL unset /m); + }); + + test('GBRAIN_HOME unset → MATCH', () => { + const r = check({}); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^TARGET: MATCH /m); + }); + + test('same URL in both configs → MATCH', () => { + writeConfig(gbrainHome, { engine: 'postgres', database_url: DEFAULT_URL }); + expect(check({ GBRAIN_HOME: gbrainHome }).stdout).toMatch(/^TARGET: MATCH /m); + }); + + test('default config without a database_url → MATCH (the old rule locked nothing)', () => { + writeConfig(tmpHome, { engine: 'pglite' }); + expect(check({ GBRAIN_HOME: gbrainHome }).stdout).toMatch(/^TARGET: MATCH /m); + }); + + test('GBRAIN_HOME has no config but the default brain has a URL → MISMATCH', () => { + fs.rmSync(path.join(gbrainHome, '.gbrain'), { recursive: true, force: true }); + expect(check({ GBRAIN_HOME: gbrainHome }).stdout).toMatch(/^TARGET: MISMATCH /m); + }); + + test('writes nothing: configs unchanged, no gstack state, no worktree', () => { + const configs = [tmpHome, gbrainHome].map((d) => path.join(d, '.gbrain', 'config.json')); + const before = configs.map((p) => fs.readFileSync(p, 'utf-8')); + check({ GBRAIN_HOME: gbrainHome }); + expect(configs.map((p) => fs.readFileSync(p, 'utf-8'))).toEqual(before); + for (const dir of [tmpHome, gbrainHome]) expect(fs.readdirSync(path.join(dir, '.gbrain'))).toEqual(['config.json']); + expect(fs.existsSync(gstackHome)).toBe(false); + expect(fs.existsSync(worktreeDir)).toBe(false); + }); +}); + +// The shared helper itself (#4325): every gstack shell read of gbrain's config +// resolves through bin/gstack-gbrain-home.sh, which follows gbrain's configDir(). +describe('bin/gstack-gbrain-home.sh (#4325)', () => { + const HELPER = path.join(ROOT, 'bin/gstack-gbrain-home.sh'); + + function configPath(gbrainHome?: string) { + const env: Record = { PATH: HERMETIC_PATH, HOME: '/home/fixture' }; + if (gbrainHome !== undefined) env.GBRAIN_HOME = gbrainHome; + const r = spawnSync('bash', ['-c', '. "$1" && gstack_gbrain_config_path', 'helper', HELPER], { + env, + encoding: 'utf8', + timeout: 10_000, + }); + return { status: r.status, out: r.stdout }; + } + + test('unset or blank GBRAIN_HOME means ~/.gbrain', () => { + for (const home of [undefined, '', ' ']) { + expect(configPath(home)).toEqual({ status: 0, out: '/home/fixture/.gbrain/config.json\n' }); + } + }); + + test('GBRAIN_HOME is trimmed and is the parent of .gbrain', () => { + expect(configPath(' /srv/brain/ ')).toEqual({ status: 0, out: '/srv/brain/.gbrain/config.json\n' }); + }); + + test('a GBRAIN_HOME gbrain refuses prints nothing and fails, with no ~/.gbrain fallback', () => { + for (const home of ['relative/home', '/srv/../brain', '/srv/brain/..']) { + expect(configPath(home)).toEqual({ status: 1, out: '' }); + } + }); +}); diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 1a8cdf227..2b4887a59 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -637,6 +637,30 @@ describe('gstack-skill-start behavior', () => { expect(out).toMatch(/^ARTIFACTS_SYNC: off$/m); }); + test('the /sync-gbrain hint follows GBRAIN_HOME the way gbrain resolves its config (#4325)', () => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-gbrain-bin-')); + const named = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-gbrain-home-')); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-gbrain-empty-')); + try { + fs.writeFileSync(path.join(bin, 'gbrain'), '#!/bin/sh\necho "gbrain 0.0.0"\n', { mode: 0o755 }); + fs.mkdirSync(path.join(named, '.gbrain')); + fs.writeFileSync(path.join(named, '.gbrain', 'config.json'), '{}\n'); + const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + expect(runStart([], env)).not.toContain('GBrain configured'); + expect(runStart([], { ...env, GBRAIN_HOME: named })).toContain('GBrain configured'); + fs.mkdirSync(path.join(tmpHome, '.gbrain')); + fs.writeFileSync(path.join(tmpHome, '.gbrain', 'config.json'), '{}\n'); + try { + expect(runStart([], env)).toContain('GBrain configured'); + expect(runStart([], { ...env, GBRAIN_HOME: empty })).not.toContain('GBrain configured'); + } finally { + fs.rmSync(path.join(tmpHome, '.gbrain'), { recursive: true, force: true }); + } + } finally { + for (const dir of [bin, named, empty]) fs.rmSync(dir, { recursive: true, force: true }); + } + }); + test('artifacts-sync consent is asked before any artifacts egress, and only in interactive sessions', () => { const gh = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-privacy-')); const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-ss-privacy-bin-'));