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
co-authored by Claude Fable 5
parent a7a25aa489
commit e742648eda
9 changed files with 266 additions and 27 deletions
+5 -3
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
+59 -11
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();
+7
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 {