From 9c6b2c203d1107a53778499e0273b2a262ae3b3e Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 31 Oct 2026 18:05:28 +0000 Subject: [PATCH] fix(memory): a loopback gbrain serve --http is a local brain, not Path 4 (#3539) gbrain's recommended setup for local agents on a Postgres brain is one resident `gbrain serve --http` on loopback, registered as an http MCP entry. gstack classified every http entry as a remote brain, so memory ingest staged transcripts for a brain admin's pull that never comes. - lib/gbrain-mcp-locality.ts: one rule. An http/url/sse entry is local when its URL is http(s) on a loopback host and the local gbrain config has an engine and no remote_mcp marker. A URL with userinfo or ${ is never local. - gstack-memory-ingest: the loopback case takes the gbrain import path, and entries left staged/remote-http import on the next local run. - gstack-gbrain-detect: all three tiers use the rule; tier 2 classifies the whole URL token of the claude mcp list line. - gbrain-local-status: hasRemoteOnlyGbrainMcp uses the same classifier. - gstack-skill-start: the jq hot path carries a conservative subset. - USING_GBRAIN_WITH_GSTACK.md: a loopback serve over this machine's engine is not Path 4. Ported from #3552 onto current main. Co-authored-by: Willy Morin <3635875+wmorin@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- USING_GBRAIN_WITH_GSTACK.md | 2 + bin/gstack-gbrain-detect | 54 ++++++++------- bin/gstack-memory-ingest.ts | 37 +++++++--- bin/gstack-skill-start | 23 +++++- lib/gbrain-local-status.ts | 16 ++--- lib/gbrain-mcp-locality.ts | 135 ++++++++++++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 lib/gbrain-mcp-locality.ts diff --git a/USING_GBRAIN_WITH_GSTACK.md b/USING_GBRAIN_WITH_GSTACK.md index 7b8d40cf4..271c5fbf3 100644 --- a/USING_GBRAIN_WITH_GSTACK.md +++ b/USING_GBRAIN_WITH_GSTACK.md @@ -74,6 +74,8 @@ If you accept the local PGLite, you end up in **split-engine mode**: - **Brain/context queries** (`mcp__gbrain__search`, `mcp__gbrain__query`, `mcp__gbrain__get_page`) route to the remote MCP. Plans, retros, learnings, cross-machine memory — all on the shared server. - **Code queries** (`gbrain code-def`, `code-refs`, `code-callers`, `code-callees`, `gbrain search` for code) route to the local PGLite via the `.gbrain-source` pin in each worktree. Indexed locally, fast, never leaves the machine. +A `gbrain serve --http` on this machine's loopback address (`localhost`, `127.0.0.1`, `::1`) over this machine's own engine is not Path 4. That is gbrain's recommended setup for local agents on a Postgres brain (`gbrain bootstrap harness`), and gstack treats it as a local brain: transcripts go through `gbrain import` here. gstack decides this from the URL and the local gbrain config alone, so an SSH tunnel or port forward to a remote brain on a loopback port, on a machine whose gbrain config also names an engine, also counts as local. + The two engines are independent. Wiping the local PGLite doesn't touch the remote brain; rotating the remote MCP bearer doesn't affect local code search. This is also the right configuration if your remote brain admin can't (or shouldn't) index every developer's checkout — local code stays local. ## MCP registration for Claude Code diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index e79f4d558..0183f549d 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -45,6 +45,7 @@ import { readGbrainVersion, } from "../lib/gbrain-local-status"; import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec"; +import { classifyGbrainMcpEntry, classifyGbrainMcpListLine, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality"; import { resolveStateRoot } from "../lib/state-root"; const STATE_DIR = resolveStateRoot(); @@ -154,7 +155,19 @@ function detectBrainGit(): boolean { // 1. `claude mcp get gbrain --json` — public CLI surface, structured output // 2. `claude mcp list` text-grep — older claude versions without --json // 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH -function detectMcpMode(): "local-stdio" | "remote-http" | "none" { +// +// #3539: an HTTP entry on a loopback host whose local gbrain config has an +// engine and no remote_mcp marker is a resident `gbrain serve --http` of this +// machine's own brain. It reports "local-stdio" (the local-engine mode every +// consumer already understands: memory ingest imports, sync-gbrain skips the +// remote trust-policy question) rather than a new value. +type McpMode = "local-stdio" | "remote-http"; +function classify(entry: GbrainMcpEntry): McpMode | null { + const c = classifyGbrainMcpEntry(entry); + return c === "remote" ? "remote-http" : c === "local" ? "local-stdio" : null; +} + +function detectMcpMode(): McpMode | "none" { const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null; if (claudeOnPath) { // Tier 1: `claude mcp get gbrain --json` @@ -167,11 +180,8 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" { command?: string; url?: string; }; - const mtype = parsed.type || parsed.transport || ""; - if (mtype === "http" || mtype === "sse") return "remote-http"; - if (mtype === "stdio") return "local-stdio"; - if (parsed.url) return "remote-http"; - if (parsed.command) return "local-stdio"; + const c = classify(parsed); + if (c) return c; } catch { // fall through } @@ -179,34 +189,28 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" { // 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. + // false-positive. The exact `gbrain:` line wins over a variant, the same + // order as tier 3 (and memory-ingest, which reads mcpServers.gbrain): + // {gbrain-a: loopback, gbrain: remote} is remote-http. const list = tryExec("claude", ["mcp", "list"], 3_000); if (list) { - const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l)); + const lines = list.split("\n"); + const line = + lines.find((l) => l.startsWith("gbrain:")) ?? lines.find((l) => /^gbrain[-_][\w-]*:/.test(l)); if (line) { - if (/\b(http|HTTP)\b/.test(line)) return "remote-http"; - return "local-stdio"; + // The list line carries the URL for HTTP and SSE servers + // ("gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected"), so the + // loopback rule (#3539) applies here too. The whole URL token is + // classified, never cut at ")"; an HTTP or SSE line without a usable + // URL keeps the old remote-http answer. + return classifyGbrainMcpListLine(line) === "local" ? "local-stdio" : "remote-http"; } } } // Tier 3: read ~/.claude.json directly - interface McpServerEntry { - type?: string; - transport?: string; - command?: string; - url?: string; - } const cj = tryReadJSON(CLAUDE_JSON) as - | { mcpServers?: Record } + | { mcpServers?: Record } | null; - 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) { diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index b2a69fe1a..dba4605fe 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -69,6 +69,7 @@ import { withErrorContext, } from "../lib/gstack-memory-helpers"; import { execGbrainText, gbrainConfigDir, spawnGbrain, spawnGbrainAsync } from "../lib/gbrain-exec"; +import { classifyGbrainMcpEntry, type GbrainMcpEntry } from "../lib/gbrain-mcp-locality"; import { constrainSourceId, parseSourcesList } from "../lib/gbrain-sources"; import { BISECT_AFTER_REFUSALS, @@ -427,9 +428,19 @@ function fileSha256(path: string): string { } } +/** + * #3539: a page staged for a remote brain's pull (`staged` / `remote-http`) + * was never imported here. Once the brain is local (a loopback serve, or the + * entry switched back to stdio), that page has not landed, so it must be + * prepared again instead of counting as unchanged. + */ +function stagedForRemotePull(entry: StateEntry | undefined): boolean { + return !!entry && entry.status === "staged" && entry.source_id === "remote-http" && !isRemoteHttpMcpMode(); +} + function fileChangedSinceState(path: string, state: IngestState, verifyHash = false): boolean { const entry = state.sessions[path]; - if (isRetryable(entry)) return true; + if (isRetryable(entry) || stagedForRemotePull(entry)) return true; try { const st = statSync(path); const mtimeNs = Math.floor(st.mtimeMs * 1e6); @@ -1418,7 +1429,7 @@ async function probeMode(args: CliArgs): Promise { totalBytes += size; const entry = state.sessions[path]; - if (!entry) newCount++; + if (!entry || stagedForRemotePull(entry)) newCount++; else if (fileChangedSinceState(path, state, args.scanSecrets)) updatedCount++; else unchangedCount++; } @@ -1800,23 +1811,27 @@ function makePersistentTranscriptDir(): string { * * Reads ~/.claude.json directly (same fallback chain as gstack-gbrain-detect * Tier 3). Cheap: one fs read, no fork-exec. + * + * #3539: an HTTP entry on a loopback host that reaches this machine's own + * gbrain engine (a resident `gbrain serve --http`) is not remote; see + * lib/gbrain-mcp-locality.ts. Memoized: the change check consults it per + * state entry, and ~/.claude.json can be megabytes. */ +let remoteHttpMcpMode: boolean | undefined; function isRemoteHttpMcpMode(): boolean { + remoteHttpMcpMode ??= readRemoteHttpMcpMode(); + return remoteHttpMcpMode; +} + +function readRemoteHttpMcpMode(): boolean { const home = process.env.HOME || homedir(); const claudeJsonPath = join(home, ".claude.json"); if (!existsSync(claudeJsonPath)) return false; try { const parsed = JSON.parse(readFileSync(claudeJsonPath, "utf-8")) as { - mcpServers?: { - gbrain?: { type?: string; transport?: string; url?: string }; - }; + mcpServers?: { gbrain?: GbrainMcpEntry }; }; - const entry = parsed.mcpServers?.gbrain; - if (!entry) return false; - const mtype = entry.type || entry.transport || ""; - if (mtype === "url" || mtype === "http" || mtype === "sse") return true; - if (entry.url) return true; - return false; + return classifyGbrainMcpEntry(parsed.mcpServers?.gbrain) === "remote"; } catch { return false; } diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 8562708b7..f78c2aedb 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -395,7 +395,28 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr _GBRAIN_MCP_ENTRY=$(jq -c --arg cwd "$PWD" '((.projects // {}) | to_entries | map(select((.key as $k | $cwd == $k or ($cwd | startswith($k + "/")) or ($cwd | startswith($k + "\\"))) and ((try .value.mcpServers.gbrain catch null) != null))) | sort_by(.key | length) | last | .value.mcpServers.gbrain) // .mcpServers.gbrain // empty' "$HOME/.claude.json" 2>/dev/null) _GBRAIN_MCP_TYPE=$(printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -r '.type // .transport // empty' 2>/dev/null) case "$_GBRAIN_MCP_TYPE" in - url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;; + url|http|sse) + _GBRAIN_MCP_MODE="remote-http" + # #3539: a loopback URL whose local gbrain config has an engine and no + # remote_mcp marker is this machine's own `gbrain serve --http`, not a + # remote brain. A conservative subset of lib/gbrain-mcp-locality.ts, not + # an exact mirror: the URL test accepts only canonical loopback + # spellings (localhost, 127.a.b.c with valid decimal octets, [::1], a + # valid port), so other spellings stay remote here. It lowercases with + # ascii_downcase, not the "i" flag, which case-folds U+017F to "s". As in + # TS, a URL containing "${" is remote (Claude Code expands it), and the + # regex rejects userinfo. The config test + # matches the TS one: remote_mcp is a marker when JS-truthy, as in + # gbrain's isThinClient (null, false, 0 and "" are not), and + # database_url counts when non-blank after JS trim(). GBRAIN_HOME is the + # parent of .gbrain, as in gbrainConfigDir. test/gbrain-mcp-locality.test.ts + # runs both programs below against the TS rule. + if printf '%s' "$_GBRAIN_MCP_ENTRY" | jq -e '(.url // "") | ascii_downcase | (contains("${") | not) and test("\\Ahttps?://(localhost|127(\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|\\[::1\\])(:([0-9]{0,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?([/?#]|\\z)")' >/dev/null 2>&1 \ + && jq -e '((.remote_mcp // false) | . == false or . == 0 or . == "") and (.engine == "pglite" or ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}"))))' \ + "${GBRAIN_HOME:-$HOME}/.gbrain/config.json" >/dev/null 2>&1; then + _GBRAIN_MCP_MODE="local-stdio" + fi + ;; stdio) _GBRAIN_MCP_MODE="local-stdio" ;; esac fi diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 7f18a8c46..1dbeb7caa 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -54,6 +54,7 @@ import { atomicWriteSync } from "./fs-atomic"; import { homedir } from "os"; import { dirname, join } from "path"; import { buildGbrainEnv, gbrainChildCwd, gbrainConfigDir, isExecTimeout, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { classifyGbrainMcpEntry } from "./gbrain-mcp-locality"; import { resolveStateRoot } from "./state-root"; export type LocalEngineStatus = @@ -187,16 +188,11 @@ export function hasRemoteOnlyGbrainMcp( } catch { return false; } - // Same classification rules as gstack-gbrain-detect's detectMcpMode tier 3, - // including the #2051 name generalization (gbrain, gbrain-remote, gbrain_work). - const classify = (entry: McpEntry): "remote" | "local" | null => { - const mtype = entry.type || entry.transport || ""; - if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote"; - if (mtype === "stdio") return "local"; - if (entry.url) return "remote"; - if (entry.command) return "local"; - return null; - }; + // The classifier gstack-gbrain-detect's detectMcpMode uses, so a loopback + // `gbrain serve --http` over this machine's own engine is local here too + // (#3539): detect never prints local-stdio next to thin-client. Name + // generalization (#2051: gbrain, gbrain-remote, gbrain_work) is below. + const classify = (entry: McpEntry): "remote" | "local" | null => classifyGbrainMcpEntry(entry, env ?? process.env); /** Extract the gbrain-relevant entries from an mcpServers object. */ const gbrainEntries = (servers: unknown): Record => { const out: Record = {}; diff --git a/lib/gbrain-mcp-locality.ts b/lib/gbrain-mcp-locality.ts new file mode 100644 index 000000000..c852f99c0 --- /dev/null +++ b/lib/gbrain-mcp-locality.ts @@ -0,0 +1,135 @@ +/** + * gbrain-mcp-locality — is a registered gbrain MCP entry a remote brain? (#3539) + * + * An http/url/sse MCP entry used to mean "Path 4: the brain lives on another + * machine", so transcripts were staged for a brain admin's pull instead of + * going through `gbrain import`. gbrain's own recommended setup for local + * agents on a Postgres brain is one resident `gbrain serve --http` on + * loopback, registered by `gbrain bootstrap harness`. That entry is HTTP but + * the brain is on this machine, and no brain admin will ever index the + * staged pages. + * + * Rule: an HTTP entry is LOCAL when its URL is http(s) on a loopback host AND + * this machine's gbrain config has an engine (`engine: "pglite"` or a + * `database_url`) and no `remote_mcp` thin-client marker. Every other HTTP + * entry stays remote, as before. stdio entries are local, as before. Any + * other URL scheme (ws:, file:) stays remote. The marker test is gbrain's own + * isThinClient() truthiness (`!!remote_mcp`), so `remote_mcp: 0` or `""` is + * not a marker. + * + * File reads only: no subprocess, no network (a classifier network probe is + * the #1964 pathology). + * + * bin/gstack-skill-start carries a jq copy of this rule for its hot path. It + * is a conservative subset, not an exact mirror: its URL regex accepts only + * canonical loopback spellings (localhost, 127.a.b.c without leading zeros, + * [::1]), so whenever jq says local this file says local too, while some + * spellings this file accepts (127.1, 0x7f.1, 127.0.0.1.) stay remote in jq. + * The config half matches exactly. test/gbrain-mcp-locality.test.ts runs both + * copies over the same table and enforces that relation. + */ + +import { readFileSync } from "fs"; +import { join } from "path"; +import { gbrainConfigDir } from "./gbrain-exec"; + +export interface GbrainMcpEntry { + type?: string; + transport?: string; + command?: string; + url?: string; +} + +/** + * True when the URL is http(s) and its host is localhost, 127.0.0.0/8 or ::1. + * A URL with userinfo, or containing `${`, is never loopback: Claude Code + * expands `${VAR}` in `url` before connecting, so `http://${X}@127.0.0.1/mcp` + * can dial any host. + */ +export function isLoopbackMcpUrl(url: string | undefined | null): boolean { + if (typeof url !== "string" || url.trim() === "" || url.includes("${")) return false; + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + const host = parsed.hostname.toLowerCase(); + if (host === "localhost" || host === "::1" || host === "[::1]") return true; + // WHATWG URL normalizes IPv4 forms (127.1, 0x7f.1) to dotted quads. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +/** + * This machine's gbrain config (gbrainConfigDir()/config.json, honoring + * GBRAIN_HOME) names a local engine and carries no remote_mcp marker + * (truthy, as in gbrain's isThinClient). + */ +export function hasLocalGbrainEngine(env: NodeJS.ProcessEnv = process.env): boolean { + try { + const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env), "config.json"), "utf-8")) as { + engine?: unknown; + database_url?: unknown; + remote_mcp?: unknown; + } | null; + if (!cfg || typeof cfg !== "object") return false; + if (cfg.remote_mcp) return false; + if (cfg.engine === "pglite") return true; + return typeof cfg.database_url === "string" && cfg.database_url.trim() !== ""; + } catch { + return false; + } +} + +/** An HTTP MCP URL that reaches this machine's own gbrain engine. */ +export function isLocalHttpGbrain(url: string | undefined | null, env: NodeJS.ProcessEnv = process.env): boolean { + return isLoopbackMcpUrl(url) && hasLocalGbrainEngine(env); +} + +/** + * Classify one MCP registration: "remote" for an HTTP brain on another + * machine, "local" for stdio or a loopback HTTP serve of the local engine, + * null when the entry carries no transport signal. + */ +export function classifyGbrainMcpEntry( + entry: GbrainMcpEntry | null | undefined, + env: NodeJS.ProcessEnv = process.env, +): "local" | "remote" | null { + if (!entry || typeof entry !== "object") return null; + // Same precedence as the transport-only rule this replaces: explicit HTTP + // type, explicit stdio, then a bare url, then a bare command. + const mtype = entry.type || entry.transport || ""; + const remoteUnlessLoopback = (): "local" | "remote" => (isLocalHttpGbrain(entry.url, env) ? "local" : "remote"); + if (mtype === "url" || mtype === "http" || mtype === "sse") return remoteUnlessLoopback(); + if (mtype === "stdio") return "local"; + if (entry.url) return remoteUnlessLoopback(); + if (entry.command) return "local"; + return null; +} + +/** + * Classify the gbrain line of `claude mcp list`. HTTP and SSE servers print + * as `gbrain: (HTTP) - ✓ Connected` (or `(SSE)`); stdio servers print + * their command with no transport marker. + * + * The URL token is everything between the name and the LAST ` (HTTP)` / + * ` (SSE)` marker. It is never cut at `)`: a cut turns + * `http://127.0.0.1)@evil.com/mcp` (host evil.com) into `http://127.0.0.1`. + * A token containing whitespace, or one that is not a loopback http(s) URL + * over a local engine, is remote. A line that names HTTP/SSE but has no + * such token is remote, as before; a line with neither is stdio (local). + */ +export function classifyGbrainMcpListLine( + line: string, + env: NodeJS.ProcessEnv = process.env, +): "local" | "remote" { + const m = /^gbrain(?:[-_][\w-]*)?:\s+(.+)\s+\((?:HTTP|SSE)\)/.exec(line); + if (m) { + const token = m[1]; + return !/\s/.test(token) && isLocalHttpGbrain(token, env) ? "local" : "remote"; + } + if (/\b(?:http|HTTP)\b/.test(line) || /\((?:SSE|sse)\)/.test(line)) return "remote"; + return "local"; +}