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>
This commit is contained in:
authored and GitHub committed 2026-11-13 17:29:07 +00:00
1 parent 1d3c63c53d
commit 43c815be88
7 files changed
+336 -20

No files matched your search

+3 -2
View File
@@ -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 <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 <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. |
+35
View File
@@ -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"
}
+79 -12
View File
@@ -13,6 +13,7 @@
# [--database-url <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 <message> [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 <message> [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
}
case "$MODE" in
probe) do_probe ;;
wireup) do_wireup ;;
uninstall) do_uninstall ;;
advance-only) do_advance_only ;;
check-target) do_check_target ;;
esac
+8 -6
View File
@@ -390,9 +390,11 @@ _BRAIN_CONFIG_BIN="$_BIN/gstack-config"
# /sync-gbrain context-load hint. Per-worktree pin via kubectl-style
# .gbrain-source at the git toplevel; empty output when gbrain is not
# configured (zero context cost for non-gbrain users).
_GBRAIN_CONFIG="$HOME/.gbrain/config.json"
if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/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
;;
+4
View File
@@ -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.
+183
View File
@@ -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<string, unknown>) {
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<string, unknown>) {
fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true });
fs.writeFileSync(path.join(dir, '.gbrain', 'config.json'), JSON.stringify(config));
}
function check(env: Record<string, string>) {
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<string, string> = { 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: '' });
}
});
});
+24
View File
@@ -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-'));