mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 20:07:08 +01:00
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:
7 files changed
+336
-20
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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.
|
||||
@@ -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: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-'));
|
||||
|
||||
Reference in new issue
Block a user