fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051)

A thin client (remote-HTTP MCP brain, no local engine by design) probed
`gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients
(exit 1, no recognized error string), so the classifier fell to its
defensive broken-config default and every suppression gate silently hid
brain-aware blocks from exactly the users on a shared team brain.

New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp
config marker via the existing gbrainConfigPath() helper (mirrors gbrain's
isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string
drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote
reachability is deliberately NOT probed by the classifier — that is the
#1964 pathology; gbrain calls degrade gracefully at use time, and the detect
JSON says so honestly (gbrain_thin_client: {probed: false}).

The state is admitted at every suppression gate — gstack-gbrain-detect
--is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override,
gstack-config gbrain-refresh — while the sync stages (code/memory/dream)
SKIP with an accurate reason: code indexing runs on the brain server, memory
syncs via the remote brain's artifacts pull. The two consumer classes need
opposite answers, which is why this is a distinct state and not a
skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose
route thin-client to proceed, never into broken-config remediation.

detectMcpMode secondary generalization: url-match against the config's
remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path)
-> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a
3-value enum.

Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still
exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback
classifier paths; 4 detectMcpMode identification cases incl. a non-matching
url that must NOT false-positive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-07-09 19:29:37 -07:00
parent a7a25aa489
commit e742648eda
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
9 changed files with 266 additions and 27 deletions

View File

@ -411,9 +411,11 @@ case "${1:-}" in
fi
case "$STATUS" in
ok|timeout)
# "timeout" = slow-but-healthy engine (#1964) — same treatment as
# "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs.
ok|timeout|thin-client)
# "timeout" = slow-but-healthy engine (#1964); "thin-client" =
# remote-HTTP MCP brain, no local engine by design (#2051) — same
# treatment as "ok", matching gstack-gbrain-detect --is-ok and
# gen-skill-docs.
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
# Render brain-aware blocks INTO the global install so EVERY project's
# Claude sessions get them (other projects read SKILL.md + sections from

View File

@ -175,10 +175,13 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
// fall through
}
}
// Tier 2: `claude mcp list` text-grep
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
// still counts. Anchored to the gbrain token so unrelated servers can't
// false-positive.
const list = tryExec("claude", ["mcp", "list"], 3_000);
if (list) {
const line = list.split("\n").find((l) => /^gbrain:/.test(l));
const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
if (line) {
if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
return "local-stdio";
@ -186,20 +189,56 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
}
}
// Tier 3: read ~/.claude.json directly
interface McpServerEntry {
type?: string;
transport?: string;
command?: string;
url?: string;
}
const cj = tryReadJSON(CLAUDE_JSON) as
| { mcpServers?: { gbrain?: { type?: string; transport?: string; command?: string; url?: string } } }
| { mcpServers?: Record<string, McpServerEntry> }
| null;
const entry = cj?.mcpServers?.gbrain;
if (entry) {
const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
const mtype = entry.type || entry.transport || "";
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
if (mtype === "stdio") return "local-stdio";
if (entry.url) return "remote-http";
if (entry.command) return "local-stdio";
return null;
};
const servers = cj?.mcpServers || {};
const exact = servers["gbrain"];
if (exact) {
const c = classify(exact);
if (c) return c;
}
// #2051 generalization, deterministic identifiers first:
// (a) a server whose url matches the config's remote_mcp.mcp_url is THE
// thin-client brain regardless of its registered name (URL-path
// heuristics are impossible — gbrain mounts at the generic /mcp);
// (b) name pattern gbrain[-_]* ;
// (c) a stdio server whose command mentions gbrain.
const remoteMcpUrl = readRemoteMcpUrl();
for (const [name, entry] of Object.entries(servers)) {
if (remoteMcpUrl && entry.url && entry.url === remoteMcpUrl) return "remote-http";
if (/^gbrain([-_][\w-]*)?$/.test(name)) {
const c = classify(entry);
if (c) return c;
}
if (entry.command && /\bgbrain\b/.test(entry.command)) return "local-stdio";
}
return "none";
}
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
function readRemoteMcpUrl(): string {
const gbrainHome = process.env.GBRAIN_HOME || join(userHome(), ".gbrain");
const cfg = tryReadJSON(join(gbrainHome, "config.json")) as
| { remote_mcp?: { mcp_url?: string } }
| null;
return cfg?.remote_mcp?.mcp_url || "";
}
// --- artifacts remote URL with brain-* fallback during the rename migration window ---
function detectArtifactsRemote(): string {
const newPath = join(userHome(), ".gstack-artifacts-remote.txt");
@ -237,20 +276,29 @@ function main(): void {
gbrain_pooler_mode: detectPoolerMode(),
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
// #2051 honesty marker: on a thin client the classifier verified the CONFIG
// (remote_mcp present), not the remote's reachability — that is checked at
// use time, where gbrain calls degrade gracefully.
const withThinClient =
out.gbrain_local_status === "thin-client"
? { ...out, gbrain_thin_client: { probed: false } }
: out;
process.stdout.write(JSON.stringify(withThinClient, null, 2) + "\n");
}
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok", or
// "timeout" — a slow-but-healthy engine, #1964 — slow must not silently
// suppress brain features), 1 otherwise. Runs detection live (never reads
// the possibly-stale gbrain-detection.json), so callers — setup,
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok";
// "timeout" — a slow-but-healthy engine, #1964; or "thin-client" — remote-HTTP
// MCP brain with no local engine by design, #2051 — neither slow nor remote
// must silently suppress brain features), 1 otherwise. Runs detection live
// (never reads the possibly-stale gbrain-detection.json), so callers — setup,
// bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to
// render the gbrain :user variant without duplicating the JSON grep.
// Prints nothing on stdout.
if (process.argv.includes("--is-ok")) {
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
const status = localEngineStatus({ noCache });
process.exit(status === "ok" || status === "timeout" ? 0 : 1);
process.exit(status === "ok" || status === "timeout" || status === "thin-client" ? 0 : 1);
}
main();

View File

@ -719,6 +719,9 @@ function dreamMarkerPid(): number | null {
* broken-db "config points at unreachable DB; see /setup-gbrain Step 1.5"
* timeout kept for Record totality; stages PROCEED on timeout (#1964)
* via the gate's warnProbeTimeout path, never this skip.
* thin-client remote-HTTP MCP brain, no local engine by design (#2051);
* local sync stages skip (gbrain refuses sources/sync there),
* but suppression gates treat the brain as USABLE.
*/
function skipStageForLocalStatus(
stage: "code" | "memory" | "dream",
@ -735,6 +738,10 @@ function skipStageForLocalStatus(
"config points at unreachable DB; see /setup-gbrain Step 1.5",
"timeout":
"engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow",
"thin-client":
"thin client (remote-HTTP MCP brain, no local engine by design, #2051); " +
"code indexing runs on the brain server, memory syncs via the remote " +
"brain's artifacts pull — nothing to do locally",
};
const reason = reasons[status as Exclude<LocalEngineStatus, "ok">];
return {

View File

@ -22,6 +22,12 @@
* Timeout probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
* recognized error engine is likely healthy but slow (e.g. a cold
* pooler connection, #1964). Consumers treat this as usable.
* Thin-client config carries gbrain's remote_mcp marker (#2051): NO local
* engine by design; queries go to a remote-HTTP MCP brain. Usable
* for brain-aware prose gates; sync stages that need a LOCAL engine
* (code/memory/dream) skip. Remote reachability is verified at USE
* time (gbrain calls degrade gracefully), never by a classifier
* network probe that's the #1964 pathology.
* Ok DB reachable, sources list returned valid JSON.
*/
@ -47,7 +53,8 @@ export type LocalEngineStatus =
| "missing-config"
| "broken-config"
| "broken-db"
| "timeout";
| "timeout"
| "thin-client";
export interface ClassifyOptions {
/** Bypass the 60s cache. Used after any state-mutating operation. */
@ -258,6 +265,26 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
// 2. Config file present?
if (!existsSync(gbrainConfigPath(env))) return "missing-config";
// 2.5 Thin client? gbrain's own marker (mirrors gbrain isThinClient():
// truthy remote_mcp in config). A thin client has NO local engine — gbrain
// REFUSES `sources` commands on it (THIN_CLIENT_REFUSED_COMMANDS, exit 1
// with no recognized error string), so the probe below would fall to the
// defensive broken-config default and silently suppress brain-aware blocks
// (#2051). Detected PRE-probe from the config file: zero network cost,
// 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 {
remote_mcp?: unknown;
};
if (cfg && typeof cfg === "object" && cfg.remote_mcp) {
return "thin-client";
}
} catch {
// Unparseable config: fall through to the probe, whose stderr
// classification surfaces broken-config with the raw error upstream.
}
// 3. Probe gbrain sources list.
//
// Seed DATABASE_URL from ~/.gbrain/config.json (via buildGbrainEnv, the
@ -288,7 +315,11 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
if (e.code === "ENOENT") return "no-cli";
// Pattern match against gbrain's known error strings. Order matters:
// "Cannot connect to database" is the more specific DB-unreachable signal.
// thin-client refusal first (backstop for a config the pre-probe check
// couldn't read — gbrain's dispatch guard says e.g. "`gbrain sources` is
// not routable ... (thin-client of <url>)"), then the more specific
// DB-unreachable signal.
if (/thin[- ]client/i.test(stderr)) return "thin-client";
if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config";

View File

@ -47,9 +47,15 @@ function loadGbrainOverride(): { detected: boolean } {
const detectionPath = path.join(stateDir, 'gbrain-detection.json');
try {
const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
// "timeout" = slow-but-healthy engine (#1964) — same treatment as "ok",
// matching gstack-gbrain-detect --is-ok.
return { detected: json.gbrain_local_status === 'ok' || json.gbrain_local_status === 'timeout' };
// "timeout" = slow-but-healthy engine (#1964); "thin-client" = remote-HTTP
// MCP brain with no local engine by design (#2051). Both usable — same
// treatment as "ok", matching gstack-gbrain-detect --is-ok.
return {
detected:
json.gbrain_local_status === 'ok' ||
json.gbrain_local_status === 'timeout' ||
json.gbrain_local_status === 'thin-client',
};
} catch {
return { detected: false };
}

View File

@ -66,9 +66,13 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and
the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout`
like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5
remediation.
`missing-config`, `broken-config`, `broken-db`, `timeout`, `thin-client`).
Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it never triggers
Step 1.5 remediation. Treat `thin-client` like `ok` too (#2051): the machine
is a thin client of a remote-HTTP MCP brain, no local engine by design —
brain-aware blocks render, and the detect JSON carries
`gbrain_thin_client: {probed: false}` (config verified; remote reachability
is checked at use time, where gbrain calls degrade gracefully).
Skip downstream steps that are already done. Report the detected state in
one line so the user knows what you found:

View File

@ -139,6 +139,14 @@ BEFORE invoking the orchestrator:
slow (cold pooler connection, #1964). Tell the user in one line: "Engine
probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS`
if your pooler is slow." Do NOT treat this as a broken config.
- **`thin-client`**: proceed to Step 2 — this machine is a thin client of a
remote-HTTP MCP brain (#2051): no local engine BY DESIGN, so the code,
memory, and dream stages will SKIP with a thin-client reason (code indexing
runs on the brain server; memory syncs via the remote brain's artifacts
pull). Only the brain-sync push runs locally. Tell the user in one line:
"Thin client of a remote brain — local stages skip by design; brain queries
work via remote MCP (reachability is verified at use time, not probed
here)." Do NOT route this into the broken-config remediation.
- **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain`
first."
- **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user

View File

@ -31,7 +31,7 @@ import {
utimesSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
@ -61,8 +61,10 @@ interface FakeEnv {
*/
function makeEnv(opts: {
withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow";
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow" | "thin-refusal";
withConfig?: boolean;
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
thinClientConfig?: boolean;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
const bindir = join(tmp, "bin");
@ -76,7 +78,12 @@ function makeEnv(opts: {
mkdirSync(gstackHome, { recursive: true });
mkdirSync(configDir, { recursive: true });
if (opts.withConfig) {
if (opts.thinClientConfig) {
writeFileSync(
configPath,
JSON.stringify({ remote_mcp: { mcp_url: "https://brain.example.com/mcp" } }),
);
} else if (opts.withConfig) {
writeFileSync(
configPath,
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
@ -102,7 +109,7 @@ function makeEnv(opts: {
}
function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow",
behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow" | "thin-refusal",
): string {
// "slow": healthy engine on a cold pooler connection (#1964) — sleeps past
// the (test-lowered) probe timeout, then would answer fine.
@ -127,7 +134,9 @@ exit 0
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
: behavior === "throws"
? 'echo "unexpected gbrain failure" >&2'
: "";
: behavior === "thin-refusal"
? 'echo "Error: gbrain sources is not routable to the remote brain (thin-client of https://brain.example.com/mcp)" >&2'
: "";
const exitCode = behavior === "ok" ? 0 : 1;
return `#!/bin/sh
if [ "$1" = "--version" ]; then
@ -432,3 +441,72 @@ describe("lib/gbrain-local-status — cache behavior", () => {
}
});
});
// ---------------------------------------------------------------------------
// #2051: thin-client classification + the end-to-end --is-ok gate
// ---------------------------------------------------------------------------
describe("lib/gbrain-local-status — thin-client (#2051)", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("returns 'thin-client' when config carries gbrain's remote_mcp marker (pre-probe, no engine call)", () => {
// The fake gbrain would answer "ok" if probed — proving the marker is
// read from config BEFORE any probe (zero network, no error-string
// dependence).
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' via the stderr refusal fallback when the config marker is unreadable", () => {
// Regular (non-thin) config on disk, but gbrain itself refuses with the
// dispatch-guard message — the catch-path backstop.
env = makeEnv({ withGbrain: true, gbrainBehavior: "thin-refusal", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
// The eng-review 3A tripwire: the END-TO-END gate, not just the classifier
// return. --is-ok drives setup:1299 and gstack-config gbrain-refresh — this
// exit code is what decides whether brain-aware blocks render for a
// thin-client user (the #2051 report).
it("--is-ok exits 0 on a thin-client fixture (end-to-end gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(0);
});
it("--is-ok still exits 1 on broken-config (thin-client did not widen the gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(1);
});
});

View File

@ -208,6 +208,61 @@ describe('gbrain_mcp_mode — Tier 3: ~/.claude.json jq read', () => {
);
expect(runDetect().json.gbrain_mcp_mode).toBe('none');
});
// #2051 name generalization: a gbrain server registered under a variant
// name still counts. Identification order: url-match against the config's
// remote_mcp.mcp_url (deterministic — gbrain mounts at generic /mcp so
// URL-path heuristics are impossible) → name pattern gbrain[-_]* → stdio
// command token.
test('server named gbrain-remote (name pattern) → remote-http', () => {
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'gbrain-remote': { type: 'url', url: 'https://brain.corp.example/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
});
test('arbitrarily-named server whose url matches config remote_mcp.mcp_url → remote-http', () => {
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ remote_mcp: { mcp_url: 'https://team-brain.example.com/mcp' } })
);
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'our-team-brain': { type: 'url', url: 'https://team-brain.example.com/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
});
test('unrelated server with a non-matching url does NOT false-positive → none', () => {
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.gbrain', 'config.json'),
JSON.stringify({ remote_mcp: { mcp_url: 'https://team-brain.example.com/mcp' } })
);
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { linear: { type: 'url', url: 'https://mcp.linear.app/mcp' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('none');
});
test('stdio server with gbrain in the command token → local-stdio', () => {
fs.writeFileSync(
path.join(tmpHome, '.claude.json'),
JSON.stringify({
mcpServers: { 'my-brain': { type: 'stdio', command: '/usr/local/bin/gbrain' } },
})
);
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
});
});
describe('gbrain_mcp_mode — no info anywhere', () => {