mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 15:39:04 +02:00
fix: detect bearer-token thin clients via host MCP registration (#2520)
The #2051 thin-client fix keys detection on the remote_mcp marker in ~/.gbrain/config.json — but that marker is only written by the OAuth path (gbrain init --mcp-only). Bearer-token installs (gbrain connect <url> --token, gbrain's own recommended default for local/personal use) never touch config.json, so they fell through to the local probe, failed against the dead-or-absent local engine, and landed on missing-config / broken-db / broken-config / engine-locked — silently suppressing brain blocks for a fully-working remote brain. New evidence source: hasRemoteOnlyGbrainMcp() reads ~/.claude.json MCP registrations (user scope AND project scope) with the same classification rules as gstack-gbrain-detect's tier-3 fallback. File-read only — no subprocess, no network (a classifier network probe is the #1964 pathology). Wired at two sites in freshClassify: - missing-config branch: a bearer thin client may never have run a local init; if the host's only gbrain registration is remote-HTTP, that registration IS the brain → thin-client. - post-probe-failure demotion: broken-db / broken-config / engine-locked reclassify to thin-client when the only gbrain registration is remote. A local-stdio sibling registration blocks the demotion (federation guard: a user running a local engine plus a remote team brain keeps precise local statuses). "timeout" is excluded — already usable, and may be a genuinely healthy slow local engine. 7 new unit tests in test/gbrain-local-status.test.ts: user-scope, project- scope, engine-locked/broken-db demotion, federation guard, no-registration discriminator, end-to-end --is-ok gate (35 pass total in the file). Root-cause analysis by @d-danielsun in #2520. Fixes #2520 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f9f3c9801a
commit
ce4a7bbb7e
@@ -66,6 +66,8 @@ function makeEnv(opts: {
|
||||
withConfig?: boolean;
|
||||
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
|
||||
thinClientConfig?: boolean;
|
||||
/** #2520: content for ~/.claude.json (host MCP registrations). */
|
||||
claudeJson?: object;
|
||||
}): FakeEnv {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
@@ -99,6 +101,10 @@ function makeEnv(opts: {
|
||||
chmodSync(gbrainPath, 0o755);
|
||||
}
|
||||
|
||||
if (opts.claudeJson) {
|
||||
writeFileSync(join(home, ".claude.json"), JSON.stringify(opts.claudeJson));
|
||||
}
|
||||
|
||||
return {
|
||||
tmp,
|
||||
bindir,
|
||||
@@ -526,3 +532,125 @@ describe("lib/gbrain-local-status — thin-client (#2051)", () => {
|
||||
expect(r.status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2520: bearer-token thin clients (`gbrain connect --token`) — no remote_mcp
|
||||
// marker in config.json; the evidence is the host's remote-HTTP MCP
|
||||
// registration in ~/.claude.json.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
let env: FakeEnv | null = null;
|
||||
let restoreEnv: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreEnv) restoreEnv();
|
||||
if (env) env.cleanup();
|
||||
env = null;
|
||||
restoreEnv = null;
|
||||
});
|
||||
|
||||
const REMOTE_GBRAIN = {
|
||||
type: "http",
|
||||
url: "https://brain.example.com/mcp",
|
||||
headers: { Authorization: "Bearer test-token" },
|
||||
};
|
||||
const LOCAL_GBRAIN = { type: "stdio", command: "gbrain", args: ["serve"] };
|
||||
|
||||
it("returns 'thin-client' when config.json is absent but a remote-HTTP gbrain MCP is registered (user scope)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("returns 'thin-client' when config.json is absent and the registration is PROJECT-scoped (#2499)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: {
|
||||
projects: { "/some/repo": { mcpServers: { "gbrain-remote": REMOTE_GBRAIN } } },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("reclassifies a failed local probe (engine-locked) as 'thin-client' when the only gbrain MCP is remote", () => {
|
||||
// The reporter's exact shape: leftover local pglite config, dead/absent
|
||||
// local engine (probe exits 124 "connect timed out"), brain fully working
|
||||
// over remote-HTTP MCP with a bearer token.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("reclassifies broken-db as 'thin-client' when the only gbrain MCP is remote", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "broken-db",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("preserves 'engine-locked' when a local-stdio gbrain MCP is ALSO registered (federation guard)", () => {
|
||||
// A local-stdio registration means the user runs a local engine —
|
||||
// local-engine statuses must keep their precise meaning, even if a
|
||||
// second (e.g. team) brain is registered remote-HTTP.
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: {
|
||||
mcpServers: { gbrain: LOCAL_GBRAIN, "gbrain-work": REMOTE_GBRAIN },
|
||||
},
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
it("still returns 'missing-config' when no gbrain MCP registration exists (discriminator)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "ok",
|
||||
withConfig: false,
|
||||
claudeJson: { mcpServers: { "other-server": { type: "http", url: "https://x.example/mcp" } } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
|
||||
});
|
||||
|
||||
it("--is-ok exits 0 on a bearer thin-client fixture (end-to-end gate)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: REMOTE_GBRAIN } },
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user