mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* test(memory): a loopback gbrain serve --http is a local brain (#3539)
Tests from #3552. On main they fail: 21 locality/detect cases, 1 local-status case, 3 memory-ingest cases.
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>
* 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>
* changelog: #3539 loopback gbrain serve fragment
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(memory): keep userinfo fixtures clear of the pregate secrets scan (#3539)
The pregate secrets stage read four userinfo URLs in the locality test as basic-auth credentials (creds.basic_auth_url). Use the PASSWORD placeholder token and split the port-lookalike URLs so the same strings reach the rule.
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>
* changelog: note the 90-day window for stuck staged pages (#3539)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* changelog: state the measured failing-first counts (#3539)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(memory): bound the jq parity spawn with a timeout (#3539)
test/spawnsync-timeout-tripwire.test.ts requires every sync spawn in the test trees to carry a timeout; the jq parity helper had none (remote pregate, tree 037200bc25).
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>
* test(setup-gbrain): the loopback Path 4 fixture over a local PGLite now reads local-stdio (#3539)
The owned Path 4 fixture registers its fake brain at 127.0.0.1 and then runs gbrain init --pglite in the same HOME. Under the #3539 rule a loopback HTTP entry over this machine's own engine is a local brain, so detect reports local-stdio there. free-suite (7) on PR #3727 (run 41231145675) failed on the old remote-http expectation.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(memory): count a loopback serve as local only over a Postgres brain (#3539)
Path 4's split-engine setup pairs a remote brain with a local PGLite for code search; through a tunnel on a loopback port, the PGLite config made the remote brain read as local, so transcripts would import into the code-search PGLite and the trust-policy step would be skipped. The loopback rule now needs a Postgres config (a database_url, engine not pglite), which is the setup gbrain recommends the resident serve for. The setup-gbrain Path 4 fixture expects remote-http again, as on main. Decided by GStack Minder (Captain), 2026-10-31.
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>
---------
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>
This commit is contained in:
14 files changed
+922
-50
No files matched your search
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.29.0] - 2026-11-03
|
||||
|
||||
**A `gbrain serve --http` on this machine's loopback address now counts as a local brain, so transcripts get imported again.** gbrain recommends this setup for local agents on a Postgres brain (`gbrain bootstrap harness`). gstack treated every http MCP entry as a brain on another machine (Path 4). So `/sync-gbrain` staged transcripts for a brain admin who doesn't exist and never ran `gbrain import` (#3539). Ported from Willy Morin's #3552.
|
||||
|
||||
### Fixed
|
||||
- **One locality rule** (`lib/gbrain-mcp-locality.ts`). An http, url or sse entry is local when its URL is http(s) on `localhost`, `127.0.0.0/8` or `::1`, and this machine's gbrain config names a Postgres engine (a `database_url`, not PGLite) with no `remote_mcp` marker. A loopback URL next to a local PGLite stays remote, so Path 4's split-engine setup through a tunnel is unchanged. A URL with userinfo or `${` is never local. Every other entry is classified as before.
|
||||
- **`gstack-memory-ingest`** imports through gbrain in that case. Pages an earlier run left `staged` / `remote-http` count as new and are imported on the next local run. Pages older than the 90-day window need one `gstack-memory-ingest --bulk --all-history` run.
|
||||
- **`gstack-gbrain-detect`** reports `local-stdio` for it in all three tiers, and `gbrain-local-status` and the `gstack-skill-start` banner use the same rule. A remote `(SSE)` line in `claude mcp list` no longer reads as local. With several gbrain servers, the exact `gbrain:` entry wins, as in the other tiers.
|
||||
|
||||
### Docs
|
||||
- `USING_GBRAIN_WITH_GSTACK.md`: a loopback serve over this machine's Postgres brain is not Path 4; a loopback tunnel next to a local PGLite still is.
|
||||
|
||||
### Tests
|
||||
- `test/gbrain-mcp-locality.test.ts` covers the rule, the list-line parser, and a check that the shipped jq copy never says local where the TypeScript rule says remote. The detect, local-status and memory-ingest tests add loopback and stuck-page cases. On main's code (the tests-first commit f3712618), 13 of the new cases fail and `test/gbrain-mcp-locality.test.ts` cannot load, because the module it tests is new.
|
||||
|
||||
## [1.93.28.0] - 2026-11-03
|
||||
|
||||
**`test/cso-snapshot-state.test.ts` no longer hangs when a paused contender is resumed too early.**
|
||||
|
||||
@@ -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 Postgres brain 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, and only a config with a Postgres `database_url` counts. A loopback URL next to a local PGLite stays Path 4, so the split-engine setup above keeps working when you reach the remote brain through an SSH tunnel or port forward on a loopback port.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.28.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.29.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+29
-25
@@ -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<string, McpServerEntry> }
|
||||
| { mcpServers?: Record<string, GbrainMcpEntry> }
|
||||
| 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) {
|
||||
|
||||
+26
-11
@@ -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<ProbeReport> {
|
||||
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;
|
||||
}
|
||||
|
||||
+23
-1
@@ -426,7 +426,29 @@ 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 names a Postgres
|
||||
# engine (a database_url, engine not pglite) 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() and engine is not
|
||||
# pglite. 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" and ((.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
|
||||
|
||||
@@ -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<string, McpEntry> => {
|
||||
const out: Record<string, McpEntry> = {};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 names a Postgres engine (a `database_url`,
|
||||
* engine not `pglite`) and no `remote_mcp` thin-client marker. Every other
|
||||
* HTTP entry stays remote, as before. A PGLite config does not count: Path 4's
|
||||
* split-engine setup pairs a remote brain with a local PGLite for code search,
|
||||
* and a remote brain reached through a tunnel on a loopback port must stay
|
||||
* remote there; a PGLite brain held open by a resident serve is engine-locked
|
||||
* for imports anyway. 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 Postgres engine (a non-blank database_url, engine not
|
||||
* pglite) and carries no remote_mcp marker (truthy, as in gbrain's
|
||||
* isThinClient).
|
||||
*/
|
||||
export function hasLocalPostgresEngine(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;
|
||||
return cfg.engine !== "pglite" && typeof cfg.database_url === "string" && cfg.database_url.trim() !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** An HTTP MCP URL that reaches this machine's own Postgres gbrain engine. */
|
||||
export function isLocalHttpGbrain(url: string | undefined | null, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return isLoopbackMcpUrl(url) && hasLocalPostgresEngine(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: <url> (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";
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.28",
|
||||
"version": "1.93.29",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -760,6 +760,50 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
});
|
||||
|
||||
// #3539: a loopback `gbrain serve --http` over this machine's own engine is
|
||||
// a local registration. Before, the transport-only copy of the rule called
|
||||
// it remote, so detect printed gbrain_mcp_mode local-stdio next to
|
||||
// gbrain_local_status thin-client for the same entry.
|
||||
it("keeps the local engine's own status for a loopback http gbrain MCP over a local Postgres (#3539)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked-v43",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: { type: "http", url: "http://127.0.0.1:3131/mcp" } } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
writeFileSync(env.configPath, JSON.stringify({ engine: "postgres", database_url: "postgres://fake" }));
|
||||
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
|
||||
});
|
||||
|
||||
it("still reclassifies a locked local PGLite as 'thin-client' behind a loopback http entry (Path 4 split-engine, #3539)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked-v43",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: { type: "http", url: "http://127.0.0.1:3131/mcp" } } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("still reclassifies as 'thin-client' when the loopback entry's config carries remote_mcp (#3539 guard)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
gbrainBehavior: "engine-locked",
|
||||
withConfig: true,
|
||||
claudeJson: { mcpServers: { gbrain: { type: "http", url: "http://127.0.0.1:3131/mcp" } } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
writeFileSync(
|
||||
env.configPath,
|
||||
JSON.stringify({ engine: "pglite", remote_mcp: { mcp_url: "http://127.0.0.1:3131/mcp" } }),
|
||||
);
|
||||
// Config marker short-circuits to thin-client before the probe; the
|
||||
// loopback entry must not turn that into a local registration.
|
||||
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
|
||||
});
|
||||
|
||||
it("still returns 'missing-config' when no gbrain MCP registration exists (discriminator)", () => {
|
||||
env = makeEnv({
|
||||
withGbrain: true,
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* #3539: a loopback `gbrain serve --http` entry over this machine's own gbrain
|
||||
* engine is a local brain, not a Path 4 remote one.
|
||||
*
|
||||
* Pins lib/gbrain-mcp-locality.ts, and checks the jq copy in
|
||||
* bin/gstack-skill-start (the preamble's hot path reads ~/.claude.json with
|
||||
* jq, not through bun) against it: a few end-to-end skill-start cases, plus a
|
||||
* table-driven parity run of the two jq programs themselves.
|
||||
*/
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
classifyGbrainMcpEntry,
|
||||
classifyGbrainMcpListLine,
|
||||
hasLocalPostgresEngine,
|
||||
isLoopbackMcpUrl,
|
||||
} from '../lib/gbrain-mcp-locality';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const START = path.join(ROOT, 'bin', 'gstack-skill-start');
|
||||
|
||||
let home: string;
|
||||
|
||||
const PG = { engine: 'postgres', database_url: 'postgresql://u@localhost:5432/brain' };
|
||||
|
||||
function writeGbrainConfig(cfg: unknown, dir = home): void {
|
||||
fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.gbrain', 'config.json'), JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-locality-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isLoopbackMcpUrl', () => {
|
||||
test.each([
|
||||
'http://127.0.0.1:3131/mcp',
|
||||
'http://127.8.9.10/mcp',
|
||||
'http://localhost:3131/mcp',
|
||||
'HTTP://LOCALHOST/mcp',
|
||||
'http://[::1]:3131/mcp',
|
||||
])('%s is loopback', (url) => {
|
||||
expect(isLoopbackMcpUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'https://brain.example.com/mcp',
|
||||
'http://127.0.0.1.evil.example/mcp',
|
||||
'http://localhost.evil.example/mcp',
|
||||
'http://10.0.0.5:3131/mcp',
|
||||
'http://0.0.0.0:3131/mcp',
|
||||
'ws://127.0.0.1:3131/mcp',
|
||||
'wss://localhost/mcp',
|
||||
'file://localhost/etc/passwd',
|
||||
'not a url',
|
||||
'',
|
||||
// Userinfo and ${VAR}: Claude Code expands ${VAR} in the url before it
|
||||
// connects, so the host it dials is not the one written here.
|
||||
'http://evil@127.0.0.1/mcp',
|
||||
'http://user:PASSWORD@localhost:3131/mcp',
|
||||
'http://:pw@127.0.0.1/mcp',
|
||||
'http://${GBRAIN_X}@127.0.0.1:3131/mcp',
|
||||
'http://127.0.0.1:3131/${GBRAIN_X}',
|
||||
])('%s is not loopback', (url) => {
|
||||
expect(isLoopbackMcpUrl(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLocalPostgresEngine', () => {
|
||||
test('a database_url on a non-PGLite engine, with no remote_mcp marker', () => {
|
||||
const env = { HOME: home };
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false); // no config
|
||||
writeGbrainConfig(PG);
|
||||
expect(hasLocalPostgresEngine(env)).toBe(true);
|
||||
writeGbrainConfig({ database_url: 'postgresql://db.example.com/brain' });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(true);
|
||||
// PGLite (Path 4's split-engine code-search brain) never counts.
|
||||
writeGbrainConfig({ engine: 'pglite', database_path: '/x/brain.pglite' });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false);
|
||||
writeGbrainConfig({ engine: 'pglite', database_url: 'postgresql://u@localhost:5432/brain' });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false);
|
||||
writeGbrainConfig({ ...PG, remote_mcp: { mcp_url: 'http://127.0.0.1:3131/mcp' } });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false);
|
||||
writeGbrainConfig({ database_url: ' ' });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false);
|
||||
// gbrain's isThinClient is `!!remote_mcp`: falsy values are not markers.
|
||||
for (const remote_mcp of [null, false, 0, '']) {
|
||||
writeGbrainConfig({ ...PG, remote_mcp });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(true);
|
||||
}
|
||||
for (const remote_mcp of [{}, [], 'x', 1, true]) {
|
||||
writeGbrainConfig({ ...PG, remote_mcp });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('honors GBRAIN_HOME as the parent of .gbrain, like gbrainConfigDir', () => {
|
||||
const gbrainHome = path.join(home, 'elsewhere');
|
||||
writeGbrainConfig(PG, gbrainHome);
|
||||
expect(hasLocalPostgresEngine({ HOME: home })).toBe(false);
|
||||
expect(hasLocalPostgresEngine({ HOME: home, GBRAIN_HOME: gbrainHome })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGbrainMcpEntry', () => {
|
||||
const loopback = { type: 'http', url: 'http://127.0.0.1:3131/mcp' };
|
||||
|
||||
test('loopback http entry over a local Postgres engine is local', () => {
|
||||
writeGbrainConfig(PG);
|
||||
expect(classifyGbrainMcpEntry(loopback, { HOME: home })).toBe('local');
|
||||
expect(classifyGbrainMcpEntry({ url: 'http://localhost:3131/mcp' }, { HOME: home })).toBe('local');
|
||||
});
|
||||
|
||||
test('loopback http entry stays remote without a local engine or with remote_mcp', () => {
|
||||
expect(classifyGbrainMcpEntry(loopback, { HOME: home })).toBe('remote');
|
||||
writeGbrainConfig({ ...PG, remote_mcp: { mcp_url: 'http://127.0.0.1:3131/mcp' } });
|
||||
expect(classifyGbrainMcpEntry(loopback, { HOME: home })).toBe('remote');
|
||||
});
|
||||
|
||||
test('loopback http entry over a local PGLite stays remote (Path 4 split-engine through a tunnel)', () => {
|
||||
writeGbrainConfig({ engine: 'pglite', database_path: '/x/brain.pglite' });
|
||||
expect(classifyGbrainMcpEntry(loopback, { HOME: home })).toBe('remote');
|
||||
});
|
||||
|
||||
test('non-loopback http entry stays remote even with a local engine', () => {
|
||||
writeGbrainConfig(PG);
|
||||
for (const type of ['http', 'sse', 'url']) {
|
||||
expect(classifyGbrainMcpEntry({ type, url: 'https://brain.example.com/mcp' }, { HOME: home })).toBe('remote');
|
||||
}
|
||||
});
|
||||
|
||||
test('stdio and command entries are local; empty entries carry no signal', () => {
|
||||
expect(classifyGbrainMcpEntry({ type: 'stdio', command: 'gbrain' }, { HOME: home })).toBe('local');
|
||||
expect(classifyGbrainMcpEntry({ command: 'gbrain' }, { HOME: home })).toBe('local');
|
||||
expect(classifyGbrainMcpEntry({}, { HOME: home })).toBeNull();
|
||||
expect(classifyGbrainMcpEntry(undefined, { HOME: home })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyGbrainMcpListLine (`claude mcp list`, detect tier 2)', () => {
|
||||
beforeEach(() => writeGbrainConfig(PG));
|
||||
|
||||
const LINES: Array<[string, 'local' | 'remote']> = [
|
||||
['gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected', 'local'],
|
||||
['gbrain: http://localhost:3131/sse (SSE) - ✓ Connected', 'local'],
|
||||
['gbrain-local: http://[::1]:3131/mcp (HTTP) - ✗ Failed to connect', 'local'],
|
||||
['gbrain: http://127.0.0.1:3131/mcp(x) (HTTP) - ✓ Connected', 'local'],
|
||||
['gbrain: /usr/local/bin/gbrain serve - ✓ Connected', 'local'],
|
||||
// A URL cut at ")" or at whitespace would read these as loopback.
|
||||
['gbrain: http://127.0.0.1)@evil.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: http://localhost)@evil.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: http://127.0.0.1 (HTTP)@evil.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: http://127.0.0.1\t.evil.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: http://127.0.0.1 .evil.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: ws://127.0.0.1:3131/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: https://brain.example.com/mcp (HTTP) - ✓ Connected', 'remote'],
|
||||
['gbrain: https://brain.example.com/sse (SSE) - ✓ Connected', 'remote'],
|
||||
['gbrain: (SSE)', 'remote'],
|
||||
['gbrain: HTTP server - ✓ Connected', 'remote'],
|
||||
// Claude Code flattens a newline/CR (and U+2028/2029, NEL, VT, FF) inside
|
||||
// a url to a space when it prints the list, so an injected "second line"
|
||||
// stays inside this one line and its URL token contains whitespace.
|
||||
// These are the lines Claude Code 2.1.311 printed for such urls.
|
||||
['gbrain: http://127.0.0.1 (HTTP) @evil.invalid/mcp (HTTP) - ✘ Failed to connect', 'remote'],
|
||||
['gbrain: https://evil.invalid/a gbrain: http://127.0.0.1:1/mcp (HTTP) - ✓ Connected (HTTP) - ✘ Failed to connect', 'remote'],
|
||||
];
|
||||
|
||||
test.each(LINES)('%j → %s', (line: string, want: 'local' | 'remote') => {
|
||||
expect(classifyGbrainMcpListLine(line, { HOME: home })).toBe(want);
|
||||
});
|
||||
|
||||
test('a loopback list line without a local engine is remote', () => {
|
||||
fs.rmSync(path.join(home, '.gbrain'), { recursive: true, force: true });
|
||||
expect(classifyGbrainMcpListLine('gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected', { HOME: home })).toBe('remote');
|
||||
});
|
||||
});
|
||||
|
||||
// Table-driven parity: the two jq programs bin/gstack-skill-start runs (read
|
||||
// out of the script, so this checks the shipped text) against the TS rule.
|
||||
// The config half must match exactly. The URL half is a conservative subset:
|
||||
// jq may say remote where TS says local (non-canonical loopback spellings such
|
||||
// as 127.1, 0x7f.1 or 127.0.0.1. that jq's regex does not accept), and
|
||||
// must never say local where TS says remote. Exact URL parity would mean
|
||||
// reimplementing WHATWG host parsing in jq; those spellings fall back to the
|
||||
// old remote-http answer, which is the safe side.
|
||||
describe('gstack-skill-start jq copy vs the TS rule (table-driven parity)', () => {
|
||||
const src = fs.readFileSync(START, 'utf-8');
|
||||
const programs = [...src.matchAll(/jq -e '([^']+)'/g)].map((m) => m[1]);
|
||||
const JQ_URL = programs.find((p) => p.startsWith('(.url // "")'));
|
||||
const JQ_CFG = programs.find((p) => p.includes('remote_mcp'));
|
||||
|
||||
function jqTrue(program: string, input: string): boolean {
|
||||
try {
|
||||
execFileSync('jq', ['-e', program], { input, stdio: ['pipe', 'ignore', 'ignore'], timeout: 30_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('both jq programs are found in bin/gstack-skill-start', () => {
|
||||
expect(JQ_URL).toBeDefined();
|
||||
expect(JQ_CFG).toBeDefined();
|
||||
});
|
||||
|
||||
// [url, expected TS answer]. jq must never be true where TS is false.
|
||||
const URLS: Array<[string, boolean]> = [
|
||||
['http://127.0.0.1:3131/mcp', true],
|
||||
['http://localhost:3131/mcp', true],
|
||||
['HTTP://LOCALHOST/mcp', true],
|
||||
['https://127.0.0.1/mcp', true],
|
||||
['http://[::1]:3131/mcp', true],
|
||||
['http://127.255.255.255:1/x', true],
|
||||
['http://127.0.0.1', true],
|
||||
['http://127.0.0.1:3131?x', true],
|
||||
['http://localhost:65535/', true],
|
||||
['http://127.0.0.1/mcp ', true],
|
||||
// Non-canonical loopback spellings: TS local, jq remote (safe direction).
|
||||
['http://127.1/mcp', true],
|
||||
['http://0x7f.1/mcp', true],
|
||||
['http://2130706433/mcp', true],
|
||||
['http://[0:0:0:0:0:0:0:1]/mcp', true],
|
||||
['http://127.0.0.1.:3131/mcp', true],
|
||||
[' http://127.0.0.1/mcp', true],
|
||||
// WHATWG IDNA maps U+017F to "s": TS local; jq's ascii_downcase does not.
|
||||
['http://localhoſt/mcp', true],
|
||||
// Userinfo and ${VAR} (expanded by Claude Code before it connects): remote.
|
||||
['http://evil@127.0.0.1/mcp', false],
|
||||
['http://user:PASSWORD@localhost:3131/mcp', false],
|
||||
['http://${GBRAIN_X}@127.0.0.1:3131/mcp', false],
|
||||
['http://127.0.0.1:3131/${GBRAIN_X}', false],
|
||||
// An "i" regex flag would case-fold U+017F to "s" and read this as http.
|
||||
['httpſ://127.0.0.1/mcp', false],
|
||||
['HTTPſ://LOCALHOST/mcp', false],
|
||||
// Remote hosts: both must say remote.
|
||||
['http://127.0.0.1)@evil.com/mcp', false],
|
||||
['http://localhost)@evil.com/mcp', false],
|
||||
['http://127.0.0.1@evil.com/mcp', false],
|
||||
['http://localhost:3131' + '@evil.com/mcp', false],
|
||||
['http://127.0.0.1:3131' + '@evil.com/mcp', false],
|
||||
['http://127.0.0.1.evil.example/mcp', false],
|
||||
['http://localhost.evil.example/mcp', false],
|
||||
['http://127.0.0.1.nip.io/mcp', false],
|
||||
['http://127.0.0.1%2eevil.com/', false],
|
||||
['http://127.0.0.1\tevil.com/', false],
|
||||
['http://127.0.0.1\nevil.com/', false],
|
||||
['http://0.0.0.0:3131/mcp', false],
|
||||
['http://[::ffff:127.0.0.1]/mcp', false],
|
||||
// Invalid hosts/ports: cannot connect; jq used to say local here.
|
||||
['http://127.0.0.999/mcp', false],
|
||||
['http://127.0.0.256/mcp', false],
|
||||
['http://127.0.0.1:99999/mcp', false],
|
||||
['http://localhost:65536/mcp', false],
|
||||
// Non-http schemes.
|
||||
['ws://127.0.0.1/mcp', false],
|
||||
['wss://localhost/mcp', false],
|
||||
['file://localhost/etc', false],
|
||||
['https://brain.example.com/mcp', false],
|
||||
['not a url', false],
|
||||
['', false],
|
||||
];
|
||||
|
||||
test.each(URLS)('url %j: TS %p, jq never local where TS is remote', (url: string, tsWant: boolean) => {
|
||||
const ts = isLoopbackMcpUrl(url);
|
||||
expect(ts).toBe(tsWant);
|
||||
if (!ts) expect(jqTrue(JQ_URL!, JSON.stringify({ url }))).toBe(false);
|
||||
});
|
||||
|
||||
test('jq agrees with TS on the canonical spellings gbrain bootstrap writes', () => {
|
||||
for (const url of ['http://127.0.0.1:3131/mcp', 'http://localhost:3131/mcp', 'http://[::1]:3131/mcp']) {
|
||||
expect(jqTrue(JQ_URL!, JSON.stringify({ url }))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
const CONFIGS: unknown[] = [
|
||||
{ engine: 'pglite' },
|
||||
{ engine: 'pglite', database_url: 'postgresql://u@localhost:5432/brain' },
|
||||
{ database_url: 'postgresql://u@localhost:5432/brain' },
|
||||
{ engine: 'postgres', database_url: 'postgresql://u@localhost:5432/brain', remote_mcp: false },
|
||||
{ engine: 'postgres', database_url: 'postgresql://db.example.com/x' },
|
||||
{ database_url: ' ' },
|
||||
{ database_url: '\n\t' },
|
||||
{ database_url: '\u00a0\u2028\u3000\ufeff' },
|
||||
{ database_url: '\u0085' },
|
||||
{ database_url: 5 },
|
||||
{ engine: 'pglite', remote_mcp: { mcp_url: 'http://127.0.0.1:3131/mcp' } },
|
||||
{ engine: 'pglite', remote_mcp: {} },
|
||||
{ engine: 'pglite', remote_mcp: [] },
|
||||
{ engine: 'pglite', remote_mcp: 'x' },
|
||||
{ engine: 'pglite', remote_mcp: 1 },
|
||||
{ engine: 'pglite', remote_mcp: true },
|
||||
{ engine: 'pglite', remote_mcp: false },
|
||||
{ engine: 'pglite', remote_mcp: 0 },
|
||||
{ engine: 'pglite', remote_mcp: '' },
|
||||
{ engine: 'pglite', remote_mcp: null },
|
||||
{ engine: 'PGLite' },
|
||||
{},
|
||||
null,
|
||||
[],
|
||||
'str',
|
||||
];
|
||||
|
||||
test.each(CONFIGS.map((c) => [c]))('config %j: jq matches TS exactly', (cfg: unknown) => {
|
||||
writeGbrainConfig(cfg);
|
||||
const ts = hasLocalPostgresEngine({ HOME: home });
|
||||
const jq = jqTrue(JQ_CFG!, fs.readFileSync(path.join(home, '.gbrain', 'config.json'), 'utf-8'));
|
||||
expect(jq).toBe(ts);
|
||||
});
|
||||
|
||||
test('malformed config: both say no local engine', () => {
|
||||
fs.mkdirSync(path.join(home, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, '.gbrain', 'config.json'), '{not json');
|
||||
expect(hasLocalPostgresEngine({ HOME: home })).toBe(false);
|
||||
expect(jqTrue(JQ_CFG!, '{not json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The skill-start preamble prints `ARTIFACTS_SYNC: remote-mode (...)` only
|
||||
// when it classifies the gbrain entry as remote-http; otherwise a cold home
|
||||
// prints `ARTIFACTS_SYNC: off`.
|
||||
describe('gstack-skill-start mirrors the rule', () => {
|
||||
function artifactsSyncLine(entry: unknown, gbrainConfig?: unknown, extraEnv: Record<string, string> = {}): string {
|
||||
const gstackHome = path.join(home, '.gstack');
|
||||
fs.mkdirSync(gstackHome, { recursive: true });
|
||||
fs.writeFileSync(path.join(gstackHome, 'config.yaml'), 'update_check: false\n');
|
||||
fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: entry } }));
|
||||
if (gbrainConfig !== undefined) writeGbrainConfig(gbrainConfig, extraEnv.GBRAIN_HOME ?? home);
|
||||
const out = execFileSync(START, ['--skill', 'testskill'], {
|
||||
timeout: 30_000,
|
||||
encoding: 'utf-8',
|
||||
cwd: home,
|
||||
env: {
|
||||
PATH: process.env.PATH!,
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
GSTACK_LEASE_DIR: path.join(gstackHome, 'leases'),
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
return out.split('\n').find((l) => l.startsWith('ARTIFACTS_SYNC:')) ?? '';
|
||||
}
|
||||
|
||||
test('loopback http entry over a local Postgres engine is not remote-mode', () => {
|
||||
expect(artifactsSyncLine({ type: 'http', url: 'http://127.0.0.1:3131/mcp' }, PG)).toBe('ARTIFACTS_SYNC: off');
|
||||
});
|
||||
|
||||
test('loopback http entry over a local PGLite stays remote-mode (Path 4 split-engine)', () => {
|
||||
expect(artifactsSyncLine({ type: 'http', url: 'http://127.0.0.1:3131/mcp' }, { engine: 'pglite' })).toMatch(
|
||||
/^ARTIFACTS_SYNC: remote-mode \(managed by brain server 127\.0\.0\.1\)$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('GBRAIN_HOME locates the local engine config', () => {
|
||||
const gbrainHome = path.join(home, 'gb');
|
||||
expect(
|
||||
artifactsSyncLine({ type: 'http', url: 'http://localhost:3131/mcp' }, PG, { GBRAIN_HOME: gbrainHome }),
|
||||
).toBe('ARTIFACTS_SYNC: off');
|
||||
});
|
||||
|
||||
test('loopback http entry with remote_mcp in the config stays remote-mode', () => {
|
||||
expect(
|
||||
artifactsSyncLine({ type: 'http', url: 'http://127.0.0.1:3131/mcp' }, { ...PG, remote_mcp: { mcp_url: 'x' } }),
|
||||
).toMatch(/^ARTIFACTS_SYNC: remote-mode \(managed by brain server 127\.0\.0\.1\)$/);
|
||||
});
|
||||
|
||||
test('non-loopback http entry stays remote-mode', () => {
|
||||
expect(artifactsSyncLine({ type: 'http', url: 'https://brain.example.com/mcp' }, PG)).toMatch(
|
||||
/^ARTIFACTS_SYNC: remote-mode \(managed by brain server brain\.example\.com\)$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -266,6 +266,168 @@ describe('gbrain_mcp_mode — Tier 3: ~/.claude.json jq read', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #3539: a loopback `gbrain serve --http` over this machine's own engine is a
|
||||
// local brain. It reports local-stdio (the local-engine mode every consumer
|
||||
// already understands), in all three tiers.
|
||||
describe('gbrain_mcp_mode — loopback serve --http over a local engine (#3539)', () => {
|
||||
const LOOPBACK = 'http://127.0.0.1:3131/mcp';
|
||||
function localEngine(extra: Record<string, unknown> = {}, dir = tmpHome): void {
|
||||
fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ engine: 'postgres', database_url: 'postgresql://u@localhost:5432/brain', ...extra }),
|
||||
);
|
||||
}
|
||||
|
||||
test('tier 1: loopback http entry + local engine → local-stdio', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: true, getJsonOutput: JSON.stringify({ type: 'http', url: LOOPBACK }) });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
test('tier 2: loopback URL on the mcp list line + local engine → local-stdio', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: `gbrain: ${LOOPBACK} (HTTP) - ✓ Connected` });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
test('tier 3: loopback http entry + local engine under GBRAIN_HOME → local-stdio', () => {
|
||||
const gbrainHome = path.join(tmpHome, 'gb');
|
||||
localEngine({}, gbrainHome);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.claude.json'),
|
||||
JSON.stringify({ mcpServers: { gbrain: { type: 'http', url: LOOPBACK, headers: { Authorization: 'Bearer x' } } } })
|
||||
);
|
||||
expect(runDetect({ GBRAIN_HOME: gbrainHome }).json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
test('loopback entry without a local engine config stays remote-http', () => {
|
||||
fs.writeFileSync(path.join(tmpHome, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { type: 'http', url: LOOPBACK } } }));
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test('loopback entry with remote_mcp in the config stays remote-http', () => {
|
||||
localEngine({ remote_mcp: { mcp_url: 'https://team-brain.example.com/mcp' } });
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: `gbrain: ${LOOPBACK} (HTTP) - ✓ Connected` });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test('non-loopback entry with a local engine stays remote-http', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: true, getJsonOutput: JSON.stringify({ type: 'http', url: 'https://brain.example.com/mcp' }) });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
// Tier 2 classifies the whole URL token. A URL cut at ")" or at whitespace
|
||||
// would read these remote hosts as 127.0.0.1 / localhost.
|
||||
test.each([
|
||||
'gbrain: http://127.0.0.1)@evil.com/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: http://localhost)@evil.com/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: http://127.0.0.1 (HTTP)@evil.com/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: http://127.0.0.1\t.evil.com/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: ws://127.0.0.1:3131/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: http://127.0.0.1.evil.example/mcp (HTTP) - ✓ Connected',
|
||||
'gbrain: HTTP server, url hidden - ✓ Connected',
|
||||
])('tier 2: %s → remote-http (local engine present)', (line: string) => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: line });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test('tier 2: a loopback URL with a ")" in its path is still local', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: 'gbrain: http://127.0.0.1:3131/mcp(x) (HTTP) - ✓ Connected' });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
// (SSE) lines carry no "http" word. Before, they fell through to
|
||||
// local-stdio even for a remote host.
|
||||
test('tier 2: remote (SSE) line → remote-http', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: 'gbrain: https://brain.example.com/sse (SSE) - ✓ Connected' });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test('tier 2: loopback (SSE) line over a local engine → local-stdio', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: 'gbrain: http://localhost:3131/sse (SSE) - ✓ Connected' });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
// Several gbrain servers: the exact `gbrain:` line wins whatever the list
|
||||
// order (the .claude.json insertion order), as in tier 3 and memory-ingest.
|
||||
test.each([
|
||||
['variant first', `gbrain-a: ${LOOPBACK} (HTTP) - ✓ Connected\ngbrain: https://team.example.com/mcp (HTTP) - ✓ Connected`],
|
||||
['exact first', `gbrain: https://team.example.com/mcp (HTTP) - ✓ Connected\ngbrain-a: ${LOOPBACK} (HTTP) - ✓ Connected`],
|
||||
])('tier 2: loopback gbrain-a + remote gbrain (%s) → remote-http', (_order: string, list: string) => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: list });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['variant first', `gbrain_work: https://team.example.com/mcp (HTTP) - ✓ Connected\ngbrain: ${LOOPBACK} (HTTP) - ✓ Connected`],
|
||||
['exact first', `gbrain: ${LOOPBACK} (HTTP) - ✓ Connected\ngbrain_work: https://team.example.com/mcp (HTTP) - ✓ Connected`],
|
||||
])('tier 2: loopback gbrain + remote gbrain_work (%s) → local-stdio', (_order: string, list: string) => {
|
||||
localEngine();
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: list });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
});
|
||||
|
||||
test('tier 2: with no exact gbrain line, the first gbrain[-_]* line counts', () => {
|
||||
localEngine();
|
||||
makeFakeClaude({
|
||||
hasGetJson: false,
|
||||
hasMcpList: true,
|
||||
mcpListOutput: `posthog: https://mcp.posthog.com/mcp (HTTP)\ngbrain-remote: https://team.example.com/mcp (HTTP) - ✓ Connected\ngbrain-a: ${LOOPBACK} (HTTP) - ✓ Connected`,
|
||||
});
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
test('tier 2: loopback (SSE) line without a local engine → remote-http', () => {
|
||||
makeFakeClaude({ hasGetJson: false, hasMcpList: true, mcpListOutput: 'gbrain: http://localhost:3131/sse (SSE) - ✓ Connected' });
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
// detect's two fields must agree for the same loopback entry: local-stdio
|
||||
// never sits next to thin-client, and remote-http never next to a local
|
||||
// engine's own failure.
|
||||
function fakeGbrainSourcesFails(message: string): void {
|
||||
fs.writeFileSync(
|
||||
path.join(fakeBinDir, 'gbrain'),
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'case "$1" in',
|
||||
' --version|version) echo "gbrain 0.60.472.0"; exit 0 ;;',
|
||||
` sources) echo "${message}" >&2; exit 1 ;;`,
|
||||
' *) exit 0 ;;',
|
||||
'esac',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
test('a loopback entry over a locked local PGLite reads remote-http + thin-client (Path 4 split-engine)', () => {
|
||||
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpHome, '.gbrain', 'config.json'), JSON.stringify({ engine: 'pglite', database_path: '/x/brain.pglite' }));
|
||||
fs.writeFileSync(path.join(tmpHome, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { type: 'http', url: LOOPBACK } } }));
|
||||
fakeGbrainSourcesFails("GBrain's local database is already open through gbrain serve (MCP, PID 4242). This brain uses PGLite");
|
||||
const { json } = runDetect();
|
||||
expect(json.gbrain_mcp_mode).toBe('remote-http');
|
||||
expect(json.gbrain_local_status).toBe('thin-client');
|
||||
});
|
||||
|
||||
test('a loopback entry over a failing local Postgres stays local-stdio and keeps its own status', () => {
|
||||
localEngine();
|
||||
fs.writeFileSync(path.join(tmpHome, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { type: 'http', url: LOOPBACK } } }));
|
||||
fakeGbrainSourcesFails('error: connect ECONNREFUSED 127.0.0.1:5432');
|
||||
const { json } = runDetect();
|
||||
expect(json.gbrain_mcp_mode).toBe('local-stdio');
|
||||
expect(json.gbrain_local_status).not.toBe('thin-client');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gbrain_mcp_mode — no info anywhere', () => {
|
||||
test('no claude binary AND no ~/.claude.json → none', () => {
|
||||
// No fake claude, no file.
|
||||
|
||||
@@ -685,6 +685,101 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
expect(staged[0]).not.toContain("_unattributed");
|
||||
});
|
||||
|
||||
describe("a loopback gbrain serve --http over the local engine is a local brain (#3539)", () => {
|
||||
const PG = { engine: "postgres", database_url: "postgresql://u@localhost:5432/brain" };
|
||||
const loopback = { mcpServers: { gbrain: { type: "http", url: "http://127.0.0.1:3131/mcp", headers: { Authorization: "Bearer x" } } } };
|
||||
const remote = { mcpServers: { gbrain: { type: "http", url: "http://fixture.invalid/mcp" } } };
|
||||
function gbrainConfig(cfg: Record<string, unknown>): void {
|
||||
mkdirSync(join(home, ".gbrain"), { recursive: true });
|
||||
writeFileSync(join(home, ".gbrain", "config.json"), JSON.stringify(cfg));
|
||||
}
|
||||
function claudeJson(cfg: unknown): void {
|
||||
writeFileSync(join(home, ".claude.json"), JSON.stringify(cfg));
|
||||
}
|
||||
function outgoing(): string[] {
|
||||
const root = join(env.GSTACK_HOME, "transcripts");
|
||||
return existsSync(root) ? readdirSync(root, { recursive: true }).map(String).filter((f: string) => f.endsWith(".md")) : [];
|
||||
}
|
||||
// The fixture gbrain answers `import` but not the landing lookup, so an
|
||||
// imported page stops at imported_unverified; either way it went through
|
||||
// `gbrain import` into a per-repository transcript source, not the
|
||||
// remote-http staging.
|
||||
const importedEntry = {
|
||||
status: expect.stringMatching(/^(ingested|imported_unverified)$/),
|
||||
source_id: expect.stringMatching(/^gstack-transcripts-/),
|
||||
};
|
||||
|
||||
it("imports through gbrain instead of staging for a brain admin", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig(PG);
|
||||
claudeJson(loopback);
|
||||
const path = attributed(source());
|
||||
const r = run([]);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stderr).not.toContain("remote-http mode");
|
||||
expect(imported()).toHaveLength(1);
|
||||
expect(outgoing()).toEqual([]);
|
||||
expect(sessions()[path]).toMatchObject(importedEntry);
|
||||
});
|
||||
|
||||
it("keeps staging for a non-loopback URL even with a local engine", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig(PG);
|
||||
claudeJson(remote);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
expect(imported()).toEqual([]);
|
||||
expect(outgoing()).toHaveLength(1);
|
||||
expect(sessions()[path]).toMatchObject({ status: "staged", source_id: "remote-http" });
|
||||
});
|
||||
|
||||
it("keeps staging for a loopback URL over a local PGLite (Path 4 split-engine through a tunnel)", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig({ engine: "pglite", database_path: join(home, ".gbrain", "brain.pglite") });
|
||||
claudeJson(loopback);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
expect(imported()).toEqual([]);
|
||||
expect(outgoing()).toHaveLength(1);
|
||||
expect(sessions()[path]).toMatchObject({ status: "staged", source_id: "remote-http" });
|
||||
});
|
||||
|
||||
it("keeps staging for a loopback URL when gbrain's config carries remote_mcp", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig({ ...PG, remote_mcp: { mcp_url: "http://127.0.0.1:3131/mcp" } });
|
||||
claudeJson(loopback);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
expect(imported()).toEqual([]);
|
||||
expect(outgoing()).toHaveLength(1);
|
||||
expect(sessions()[path]).toMatchObject({ status: "staged", source_id: "remote-http" });
|
||||
});
|
||||
|
||||
const stdio = { mcpServers: { gbrain: { type: "stdio", command: "gbrain", args: ["serve"] } } };
|
||||
for (const [label, local] of [["loopback http", loopback], ["stdio", stdio]] as const) {
|
||||
it(`re-imports pages left staged by an earlier remote-http run once the brain is local (${label})`, () => {
|
||||
scanner("clean");
|
||||
claudeJson(remote);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
expect(sessions()[path]).toMatchObject({ status: "staged", source_id: "remote-http" });
|
||||
// Still remote: the staged page counts as unchanged, as before.
|
||||
expect(run(["--probe"]).stdout).toMatch(/Unchanged:\s+1/);
|
||||
|
||||
gbrainConfig(PG);
|
||||
claudeJson(local);
|
||||
const probe = run(["--probe"]);
|
||||
expect(probe.stdout).toMatch(/New \(never ingested\):\s+1/);
|
||||
expect(probe.stdout).toMatch(/Unchanged:\s+0/);
|
||||
const r = run(["--incremental"]);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(imported()).toHaveLength(1);
|
||||
expect(sessions()[path]).toMatchObject(importedEntry);
|
||||
expect(run(["--probe"]).stdout).toMatch(/Unchanged:\s+1/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("scans remote-http pages before persistent staging", () => {
|
||||
scanner("clean");
|
||||
writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { gbrain: { type: "http", url: "http://fixture.invalid/mcp" } } }));
|
||||
|
||||
Reference in new issue
Block a user