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

+5 -2
View File
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { slugFromEnvironment } from './bin-context';
import { resolveStateRoot } from './state-root';
import { gbrainConfigDir } from './gbrain-exec';
import { wtreeFingerprint } from './script-invocation';
import type { PregateConfig } from './pregate/config';
import { buildContext, defaultUserConfigPath, describeRefusal, withFingerprint } from './pregate/context';
@@ -151,6 +152,8 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.
try { testScript = pkgText?.includes('"test"') ? JSON.parse(pkgText).scripts?.test : undefined; } catch { /* an unreadable package.json declares nothing */ }
const listDir = (dir: string) => { try { return fs.readdirSync(dir); } catch { return []; } };
const shellFiles = ['.', 'scripts', 'bin'].some(dir => listDir(path.join(root, dir)).some(f => f.endsWith('.sh')));
const gbrainDir = gbrainConfigDir(env);
const gbrainConfigLabel = env.GBRAIN_HOME?.trim() ? '$GBRAIN_HOME/.gbrain/config.json' : '~/.gbrain/config.json';
const found: Array<[string, string, string] | false> = [
has('tsconfig.json') && ['typecheck', 'tsc --noEmit', 'tsconfig.json'],
(has('biome.json') || has('biome.jsonc')) && ['lint', 'biome check .', 'biome.json'],
@@ -163,7 +166,7 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.
which('knip') && ['deadcode', 'knip', 'PATH'],
!!pkgText?.includes('"knip"') && ['deadcode', 'npx knip', 'package.json'],
which('shellcheck') && shellFiles && ['shell', 'shellcheck', 'PATH, *.sh in the root, scripts/ or bin/'],
which('gbrain') && !!env.HOME && fs.existsSync(path.join(env.HOME, '.gbrain', 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', 'PATH, ~/.gbrain/config.json'],
which('gbrain') && !!env.HOME && !!gbrainDir && fs.existsSync(path.join(gbrainDir, 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', `PATH, ${gbrainConfigLabel}`],
];
const checks = found.filter((c): c is [string, string, string] => !!c).map(([dimension, command, source]) => ({ dimension, command, source }));
const reasons: Record<string, string> = {
@@ -172,7 +175,7 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.
test: typeof testScript === 'string' && /[\r\n]/.test(testScript) ? 'the package.json test script spans lines; list it in a CLAUDE.md Health Stack section' : 'no package.json test script, pytest in pyproject.toml, Cargo.toml or go.mod',
deadcode: 'knip is not on PATH or in package.json',
shell: which('shellcheck') ? 'no *.sh file in the root, scripts/ or bin/' : 'shellcheck is not on PATH',
gbrain: 'gbrain is not on PATH or ~/.gbrain/config.json is absent',
gbrain: gbrainDir ? `gbrain is not on PATH or ${gbrainConfigLabel} is absent` : 'GBRAIN_HOME is relative or has a ".." segment, which gbrain refuses',
};
return { checks, missing: HEALTH_DIMENSIONS.filter(d => !checks.some(c => c.dimension === d)).map(dimension => ({ dimension, reason: reasons[dimension] })) };
}
+20 -12
View File
@@ -34,7 +34,7 @@
*/
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { isAbsolute, join } from "path";
import { homedir, tmpdir } from "os";
import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process";
import { resolveWindowsBash, type BashProbe } from "./script-invocation";
@@ -80,16 +80,22 @@ export function isTransactionModePooler(url: string): boolean {
}
/**
* gbrain's config directory, matching gbrain's own configDir() contract
* (#2521): `GBRAIN_HOME` is a PARENT directory — gbrain always appends
* `.gbrain` itself, so GBRAIN_HOME=/tmp/x reads /tmp/x/.gbrain/config.json.
* Unset → ~/.gbrain. Every gstack-side gbrain-config read MUST resolve
* through this helper, or gstack classifies engine status from a file
* gbrain never reads.
* gbrain's config directory, by gbrain's own configDir() rule (src/core/config.ts
* in garrytan/gbrain, #2521): `GBRAIN_HOME`, trimmed with JavaScript trim(), is a
* PARENT directory and `.gbrain` is appended, so GBRAIN_HOME=/tmp/x reads
* /tmp/x/.gbrain/config.json. Unset or blank → ~/.gbrain. gbrain refuses a
* GBRAIN_HOME that is not absolute or has a `..` segment, so for one of those
* this returns null and the caller treats it as no config; falling back to
* ~/.gbrain would read a brain gbrain never uses. bin/gstack-gbrain-home.sh is
* the shell twin, and test/gbrain-home-parity.test.ts holds both to one table.
* Every gstack-side gbrain-config read MUST resolve through this helper, or
* gstack classifies engine status from a file gbrain never reads.
*/
export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string {
if (env.GBRAIN_HOME) return join(env.GBRAIN_HOME, ".gbrain");
return join(env.HOME || homedir(), ".gbrain");
export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string | null {
const home = env.GBRAIN_HOME?.trim();
if (!home) return join(env.HOME || homedir(), ".gbrain");
if (!isAbsolute(home) || home.split(/[\\/]/).includes("..")) return null;
return join(home, ".gbrain");
}
/**
@@ -122,7 +128,9 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
const out: NodeJS.ProcessEnv = { ...baseEnv };
if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out;
const configPath = join(gbrainConfigDir(baseEnv), "config.json");
const configDir = gbrainConfigDir(baseEnv);
if (!configDir) return out;
const configPath = join(configDir, "config.json");
if (!existsSync(configPath)) return out;
let cfg: GbrainConfig = {};
@@ -244,7 +252,7 @@ export function gbrainInvocation(args: string[]): { cmd: string; argv: string[];
export function gbrainChildCwd(cwd?: string, env: NodeJS.ProcessEnv = process.env): string {
if (cwd) return cwd;
const dir = gbrainConfigDir(env);
return existsSync(dir) ? dir : tmpdir();
return dir && existsSync(dir) ? dir : tmpdir();
}
export interface SpawnGbrainOptions {
+21 -13
View File
@@ -134,9 +134,9 @@ export function cacheFilePath(): string {
* (#2521): GBRAIN_HOME is a parent dir, `.gbrain` is appended. Same
* resolution as buildGbrainEnv — both route through gbrainConfigDir.
*/
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
const e = env ?? process.env;
return join(gbrainConfigDir(e), "config.json");
function gbrainConfigPath(env?: NodeJS.ProcessEnv): string | null {
const dir = gbrainConfigDir(env ?? process.env);
return dir && join(dir, "config.json");
}
/**
@@ -244,8 +244,10 @@ export function hasRemoteOnlyGbrainMcp(
}
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
const configPath = gbrainConfigPath(env);
if (!configPath) return null;
try {
const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string };
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { engine?: string };
return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null;
} catch {
return null;
@@ -337,8 +339,10 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
}
function configFingerprint(env?: NodeJS.ProcessEnv): { mtime: number; size: number } {
const configPath = gbrainConfigPath(env);
if (!configPath) return { mtime: 0, size: 0 };
try {
const st = statSync(gbrainConfigPath(env));
const st = statSync(configPath);
return { mtime: Math.floor(st.mtimeMs), size: st.size };
} catch {
return { mtime: 0, size: 0 };
@@ -421,12 +425,15 @@ function networkFailureDetail(stderr: string, env?: NodeJS.ProcessEnv): string |
const m = stderr.match(NETWORK_ERROR_RE);
if (!m) return null;
let host = "";
try {
const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env ?? process.env), "config.json"), "utf-8"));
if (typeof cfg?.database_url === "string") {
host = new URL(cfg.database_url.replace(/^postgres(ql)?:\/\//, "http://")).hostname;
}
} catch {}
const configPath = gbrainConfigPath(env);
if (configPath) {
try {
const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
if (typeof cfg?.database_url === "string") {
host = new URL(cfg.database_url.replace(/^postgres(ql)?:\/\//, "http://")).hostname;
}
} catch {}
}
if (!host) host = stderr.match(new RegExp(`${m[1]}\\s+([A-Za-z0-9.-]+)`))?.[1] ?? "";
return dbUnreachableReason(m[1], host);
}
@@ -456,7 +463,8 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// 2. Config file present? A bearer thin client (#2520) may never have run
// a local init, so config.json can be absent while the remote-HTTP MCP
// registration IS the user's brain.
if (!existsSync(gbrainConfigPath(env))) {
const configPath = gbrainConfigPath(env);
if (!configPath || !existsSync(configPath)) {
return hasRemoteOnlyGbrainMcp(env) ? "thin-client" : "missing-config";
}
@@ -469,7 +477,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// immune to gbrain error-string drift. Remote reachability is deliberately
// NOT probed here — a classifier network probe is the #1964 pathology.
try {
const cfg = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as {
const cfg = JSON.parse(readFileSync(configPath, "utf-8")) as {
remote_mcp?: unknown;
};
if (cfg && typeof cfg === "object" && cfg.remote_mcp) {
+3 -1
View File
@@ -73,8 +73,10 @@ export function isLoopbackMcpUrl(url: string | undefined | null): boolean {
* isThinClient).
*/
export function hasLocalPostgresEngine(env: NodeJS.ProcessEnv = process.env): boolean {
const configDir = gbrainConfigDir(env);
if (!configDir) return false;
try {
const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env), "config.json"), "utf-8")) as {
const cfg = JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8")) as {
engine?: unknown;
database_url?: unknown;
remote_mcp?: unknown;
+6 -4
View File
@@ -382,8 +382,9 @@ export function detectEngineTier(): EngineDetect {
// gbrain >=0.25 dropped the top-level `engine` field
// from doctor output, so this file is the only reliable source for engine
// detection on that version. See #1415.
function gbrainConfigPath(): string {
return join(gbrainConfigDir(process.env), "config.json");
function gbrainConfigPath(): string | null {
const dir = gbrainConfigDir(process.env);
return dir && join(dir, "config.json");
}
// Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws.
@@ -433,9 +434,10 @@ function freshDetectEngineTier(): EngineDetect {
// uses engine:"postgres" for real Supabase AND any other remote postgres
// (e.g. local-postgres-for-testing). Downstream sync code treats them the
// same, so the label compression is intentional.
if (engine === "unknown") {
const configPath = gbrainConfigPath();
if (engine === "unknown" && configPath) {
try {
const cfg = JSON.parse(readFileSync(gbrainConfigPath(), "utf-8"));
const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
if (cfg?.engine === "pglite") engine = "pglite";
else if (cfg?.engine === "postgres" || cfg?.database_url) engine = "supabase";
} catch (cfgErr) {