diff --git a/bin/gstack-config b/bin/gstack-config index d9834c447..130a93e16 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -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 diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index ad2380df2..79384e747 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -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 } | 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(); diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index e55cec0ad..6af7613ac 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -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]; return { diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 9e26d5ba4..71d20ef83 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -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 )"), 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"; diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 71aa1a34c..5e11a2efe 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -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 }; } diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index f48581543..a359a9a16 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -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: diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index 2ec065472..ead64a4d2 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -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 diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts index 703adfcad..2e9163774 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -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); + }); +}); diff --git a/test/gstack-gbrain-detect-mcp-mode.test.ts b/test/gstack-gbrain-detect-mcp-mode.test.ts index ebf58c409..c4793e6fc 100644 --- a/test/gstack-gbrain-detect-mcp-mode.test.ts +++ b/test/gstack-gbrain-detect-mcp-mode.test.ts @@ -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', () => {