Health checks find gbrain's config under GBRAIN_HOME, trimmed the way gbrain does (GST-2625) (#4344)

* Health checks find gbrain config under GBRAIN_HOME, trimmed the way gbrain trims it (GST-2625)

lib/checks.ts chose the gbrain health check from ~/.gbrain/config.json only, so under GBRAIN_HOME it judged the wrong brain. gbrainConfigDir() in lib/gbrain-exec.ts now follows gbrain configDir() exactly (trim(), refuse relative or .. values, return null), every caller treats null as no config, and checks.ts uses it. bin/gstack-gbrain-home.sh trims the JavaScript trim() set instead of POSIX [[:space:]], so a NBSP- or BOM-only GBRAIN_HOME means the default home. test/gbrain-home-parity.test.ts runs one table against both resolvers.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* gstack-gbrain-detect no longer crashes when GBRAIN_HOME is relative or has a .. segment

gbrainConfigDir() now returns null for a GBRAIN_HOME gbrain refuses. The detect script joined that value at load time and threw a TypeError before printing any JSON. It now treats a refused GBRAIN_HOME as no config, like the other callers, and the shape test covers it.

Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-13 18:22:43 +00:00
1 parent 43c815be88
commit 6b033e79fa
12 files changed
+272 -47

No files matched your search

+2 -1
View File
@@ -540,7 +540,8 @@ async function main(): Promise<void> {
}
const failures = results.filter((r) => r.failed);
if (!args.quiet && failures.length > 0 && gbrainAvailable() && existsSync(join(gbrainConfigDir(), "config.json"))) {
const configDir = gbrainConfigDir();
if (!args.quiet && failures.length > 0 && gbrainAvailable() && configDir && existsSync(join(configDir, "config.json"))) {
const reasons = [...new Set(failures.map((r) => r.reason))].join("; ");
console.error(`brain context: ${failures.length}/${results.length} queries failed (${reasons})`);
}
+8 -4
View File
@@ -62,8 +62,10 @@ const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config");
// GBRAIN_HOME is a parent dir, `.gbrain` is appended) — must stay consistent
// with lib/gbrain-local-status's config resolution, or the detect JSON
// reports gbrain_local_status "ok" alongside gbrain_config_exists false for
// relocated-home users. Both route through gbrainConfigDir.
const GBRAIN_CONFIG = join(gbrainConfigDir(), "config.json");
// relocated-home users. Both route through gbrainConfigDir, which returns
// null for a GBRAIN_HOME gbrain refuses; that reads as no config.
const GBRAIN_CONFIG_DIR = gbrainConfigDir();
const GBRAIN_CONFIG = GBRAIN_CONFIG_DIR ? join(GBRAIN_CONFIG_DIR, "config.json") : null;
const CLAUDE_JSON = join(userHome(), ".claude.json");
function userHome(): string {
@@ -106,7 +108,7 @@ function detectGbrain(): { onPath: boolean; version: string | null } {
// --- gbrain config existence + engine kind ---
function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null } {
if (!existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null };
if (!GBRAIN_CONFIG || !existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null };
const parsed = tryReadJSON(GBRAIN_CONFIG) as { engine?: string } | null;
if (!parsed) return { exists: true, engine: null };
if (parsed.engine === "pglite" || parsed.engine === "postgres") {
@@ -121,6 +123,7 @@ function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null
// a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain
// and /setup-gbrain can advise users when search may require GBRAIN_PREPARE.
function detectPoolerMode(): "transaction" | "session" | "unknown" | null {
if (!GBRAIN_CONFIG) return null;
const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null;
if (!parsed?.database_url) return null;
return isTransactionModePooler(parsed.database_url) ? "transaction" : "session";
@@ -289,7 +292,8 @@ function detectMcpMode(): McpMode | "none" {
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
function readRemoteMcpUrl(): string {
const cfg = tryReadJSON(join(gbrainConfigDir(), "config.json")) as
if (!GBRAIN_CONFIG) return "";
const cfg = tryReadJSON(GBRAIN_CONFIG) as
| { remote_mcp?: { mcp_url?: string } }
| null;
return cfg?.remote_mcp?.mcp_url || "";
+24 -9
View File
@@ -6,18 +6,33 @@
#
# 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
# GBRAIN_HOME, trimmed the way JavaScript trim() trims, 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).
# lib/gbrain-exec.ts is the TypeScript twin, and test/gbrain-home-parity.test.ts
# holds both to one table. Every gstack shell read of gbrain's config goes
# through here (#4325).
#
# POSIX [[:space:]] is not trim()'s set: it misses NBSP and the BOM, and in a
# UTF-8 locale it follows the C library. So the set is spelled out as UTF-8
# bytes: ASCII tab through carriage return and space, U+00A0, U+1680,
# U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and U+FEFF.
gstack_gbrain_config_path() {
local home="${GBRAIN_HOME:-}"
home="${home#"${home%%[![:space:]]*}"}"
home="${home%"${home##*[![:space:]]}"}"
local home="${GBRAIN_HOME:-}" prev ws
local -a spaces=(' ' $'\t' $'\n' $'\v' $'\f' $'\r' $'\xc2\xa0' $'\xe1\x9a\x80'
$'\xe2\x80\x80' $'\xe2\x80\x81' $'\xe2\x80\x82' $'\xe2\x80\x83' $'\xe2\x80\x84' $'\xe2\x80\x85'
$'\xe2\x80\x86' $'\xe2\x80\x87' $'\xe2\x80\x88' $'\xe2\x80\x89' $'\xe2\x80\x8a' $'\xe2\x80\xa8'
$'\xe2\x80\xa9' $'\xe2\x80\xaf' $'\xe2\x81\x9f' $'\xe3\x80\x80' $'\xef\xbb\xbf')
while :; do
prev="$home"
for ws in "${spaces[@]}"; do
home="${home#"$ws"}"
home="${home%"$ws"}"
done
[ "$home" = "$prev" ] && break
done
if [ -z "$home" ]; then
printf '%s\n' "$HOME/.gbrain/config.json"
return 0
+3 -1
View File
@@ -2406,8 +2406,10 @@ export function transcriptSourceId(remote: string): string {
*/
function brainIsRemote(remoteHttpMode: boolean): boolean {
if (remoteHttpMode) return true;
const configDir = gbrainConfigDir();
if (!configDir) return false;
try {
const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(), "config.json"), "utf-8"));
const cfg = JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8"));
return (typeof cfg?.database_url === "string" && cfg.database_url.trim() !== "" && cfg.engine !== "pglite") || !!cfg?.remote_mcp;
} catch {
return false;