mirror of https://github.com/garrytan/gstack.git
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:
parent
a7a25aa489
commit
e742648eda
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue