mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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>
This commit is contained in:
1 parent
4924be8bc2
commit
ff81a147e4
9 files changed
+129
-63
No files matched your search
@@ -74,7 +74,7 @@ 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -397,9 +397,9 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr
|
||||
case "$_GBRAIN_MCP_TYPE" in
|
||||
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
|
||||
# #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
|
||||
@@ -408,11 +408,12 @@ if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ] && grep -q '"gbr
|
||||
# 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
|
||||
# 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" or ((.database_url // "") | (type == "string" and test("[^\\s\\x{feff}]|\\x{0085}"))))' \
|
||||
&& 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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
**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 an engine with no `remote_mcp` marker. A URL with userinfo or `${` is never local. Every other entry is classified as before.
|
||||
- **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 engine is not Path 4. A loopback tunnel to a remote brain on a machine that also has a local engine counts as local too.
|
||||
- `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.
|
||||
+14
-10
@@ -10,9 +10,13 @@
|
||||
* 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
|
||||
* 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.
|
||||
@@ -64,10 +68,11 @@ export function isLoopbackMcpUrl(url: string | undefined | null): boolean {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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 hasLocalGbrainEngine(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
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;
|
||||
@@ -76,16 +81,15 @@ export function hasLocalGbrainEngine(env: NodeJS.ProcessEnv = process.env): bool
|
||||
} | 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() !== "";
|
||||
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 gbrain engine. */
|
||||
/** 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) && hasLocalGbrainEngine(env);
|
||||
return isLoopbackMcpUrl(url) && hasLocalPostgresEngine(env);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -764,7 +764,7 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
// 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 'engine-locked' for a loopback http gbrain MCP over the local engine (#3539)", () => {
|
||||
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",
|
||||
@@ -772,7 +772,19 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
||||
claudeJson: { mcpServers: { gbrain: { type: "http", url: "http://127.0.0.1:3131/mcp" } } },
|
||||
});
|
||||
restoreEnv = applyEnv(env);
|
||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
||||
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)", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as path from 'path';
|
||||
import {
|
||||
classifyGbrainMcpEntry,
|
||||
classifyGbrainMcpListLine,
|
||||
hasLocalGbrainEngine,
|
||||
hasLocalPostgresEngine,
|
||||
isLoopbackMcpUrl,
|
||||
} from '../lib/gbrain-mcp-locality';
|
||||
|
||||
@@ -24,6 +24,8 @@ 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));
|
||||
@@ -71,54 +73,64 @@ describe('isLoopbackMcpUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLocalGbrainEngine', () => {
|
||||
test('pglite engine, or a database_url, with no remote_mcp marker', () => {
|
||||
describe('hasLocalPostgresEngine', () => {
|
||||
test('a database_url on a non-PGLite engine, with no remote_mcp marker', () => {
|
||||
const env = { HOME: home };
|
||||
expect(hasLocalGbrainEngine(env)).toBe(false); // no config
|
||||
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(hasLocalGbrainEngine(env)).toBe(true);
|
||||
writeGbrainConfig({ engine: 'postgres', database_url: 'postgresql://u@localhost:5432/brain' });
|
||||
expect(hasLocalGbrainEngine(env)).toBe(true);
|
||||
writeGbrainConfig({ engine: 'pglite', remote_mcp: { mcp_url: 'http://127.0.0.1:3131/mcp' } });
|
||||
expect(hasLocalGbrainEngine(env)).toBe(false);
|
||||
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(hasLocalGbrainEngine(env)).toBe(false);
|
||||
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({ engine: 'pglite', remote_mcp });
|
||||
expect(hasLocalGbrainEngine(env)).toBe(true);
|
||||
writeGbrainConfig({ ...PG, remote_mcp });
|
||||
expect(hasLocalPostgresEngine(env)).toBe(true);
|
||||
}
|
||||
for (const remote_mcp of [{}, [], 'x', 1, true]) {
|
||||
writeGbrainConfig({ engine: 'pglite', remote_mcp });
|
||||
expect(hasLocalGbrainEngine(env)).toBe(false);
|
||||
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({ engine: 'pglite' }, gbrainHome);
|
||||
expect(hasLocalGbrainEngine({ HOME: home })).toBe(false);
|
||||
expect(hasLocalGbrainEngine({ HOME: home, GBRAIN_HOME: gbrainHome })).toBe(true);
|
||||
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 engine is local', () => {
|
||||
writeGbrainConfig({ engine: 'pglite' });
|
||||
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({ engine: 'pglite', remote_mcp: { mcp_url: 'http://127.0.0.1:3131/mcp' } });
|
||||
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({ engine: 'pglite' });
|
||||
writeGbrainConfig(PG);
|
||||
for (const type of ['http', 'sse', 'url']) {
|
||||
expect(classifyGbrainMcpEntry({ type, url: 'https://brain.example.com/mcp' }, { HOME: home })).toBe('remote');
|
||||
}
|
||||
@@ -133,7 +145,7 @@ describe('classifyGbrainMcpEntry', () => {
|
||||
});
|
||||
|
||||
describe('classifyGbrainMcpListLine (`claude mcp list`, detect tier 2)', () => {
|
||||
beforeEach(() => writeGbrainConfig({ engine: 'pglite' }));
|
||||
beforeEach(() => writeGbrainConfig(PG));
|
||||
|
||||
const LINES: Array<[string, 'local' | 'remote']> = [
|
||||
['gbrain: http://127.0.0.1:3131/mcp (HTTP) - ✓ Connected', 'local'],
|
||||
@@ -269,7 +281,9 @@ describe('gstack-skill-start jq copy vs the TS rule (table-driven parity)', () =
|
||||
|
||||
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' },
|
||||
@@ -295,7 +309,7 @@ describe('gstack-skill-start jq copy vs the TS rule (table-driven parity)', () =
|
||||
|
||||
test.each(CONFIGS.map((c) => [c]))('config %j: jq matches TS exactly', (cfg: unknown) => {
|
||||
writeGbrainConfig(cfg);
|
||||
const ts = hasLocalGbrainEngine({ HOME: home });
|
||||
const ts = hasLocalPostgresEngine({ HOME: home });
|
||||
const jq = jqTrue(JQ_CFG!, fs.readFileSync(path.join(home, '.gbrain', 'config.json'), 'utf-8'));
|
||||
expect(jq).toBe(ts);
|
||||
});
|
||||
@@ -303,7 +317,7 @@ describe('gstack-skill-start jq copy vs the TS rule (table-driven parity)', () =
|
||||
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(hasLocalGbrainEngine({ HOME: home })).toBe(false);
|
||||
expect(hasLocalPostgresEngine({ HOME: home })).toBe(false);
|
||||
expect(jqTrue(JQ_CFG!, '{not json')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -333,25 +347,31 @@ describe('gstack-skill-start mirrors the rule', () => {
|
||||
return out.split('\n').find((l) => l.startsWith('ARTIFACTS_SYNC:')) ?? '';
|
||||
}
|
||||
|
||||
test('loopback http entry over a local engine is not remote-mode', () => {
|
||||
expect(artifactsSyncLine({ type: 'http', url: 'http://127.0.0.1:3131/mcp' }, { engine: 'pglite' })).toBe('ARTIFACTS_SYNC: off');
|
||||
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' }, { engine: 'pglite' }, { GBRAIN_HOME: gbrainHome }),
|
||||
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' }, { engine: 'pglite', remote_mcp: { mcp_url: 'x' } }),
|
||||
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' }, { engine: 'pglite' })).toMatch(
|
||||
expect(artifactsSyncLine({ type: 'http', url: 'https://brain.example.com/mcp' }, PG)).toMatch(
|
||||
/^ARTIFACTS_SYNC: remote-mode \(managed by brain server brain\.example\.com\)$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -273,7 +273,10 @@ 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: 'pglite', ...extra }));
|
||||
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', () => {
|
||||
@@ -386,28 +389,42 @@ describe('gbrain_mcp_mode — loopback serve --http over a local engine (#3539)'
|
||||
expect(runDetect().json.gbrain_mcp_mode).toBe('remote-http');
|
||||
});
|
||||
|
||||
// detect's two fields must agree: a loopback entry over a local engine that
|
||||
// gbrain reports as locked (a live serve holding PGLite) is local-stdio +
|
||||
// engine-locked, never local-stdio + thin-client.
|
||||
test('mcp_mode and local_status agree for a loopback entry over a locked local engine', () => {
|
||||
localEngine({ database_path: '/x/brain.pglite' });
|
||||
fs.writeFileSync(path.join(tmpHome, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { type: 'http', url: LOOPBACK } } }));
|
||||
// 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 "GBrain\'s local database is already open through `gbrain serve` (MCP, PID 4242). This brain uses PGLite" >&2; exit 1 ;;',
|
||||
` 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).toBe('engine-locked');
|
||||
expect(json.gbrain_local_status).not.toBe('thin-client');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -686,6 +686,7 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
});
|
||||
|
||||
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 {
|
||||
@@ -710,7 +711,7 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
|
||||
it("imports through gbrain instead of staging for a brain admin", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig({ engine: "pglite", database_path: join(home, ".gbrain", "brain.pglite") });
|
||||
gbrainConfig(PG);
|
||||
claudeJson(loopback);
|
||||
const path = attributed(source());
|
||||
const r = run([]);
|
||||
@@ -723,7 +724,7 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
|
||||
it("keeps staging for a non-loopback URL even with a local engine", () => {
|
||||
scanner("clean");
|
||||
gbrainConfig({ engine: "pglite" });
|
||||
gbrainConfig(PG);
|
||||
claudeJson(remote);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
@@ -732,9 +733,20 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
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({ engine: "pglite", remote_mcp: { mcp_url: "http://127.0.0.1:3131/mcp" } });
|
||||
gbrainConfig({ ...PG, remote_mcp: { mcp_url: "http://127.0.0.1:3131/mcp" } });
|
||||
claudeJson(loopback);
|
||||
const path = attributed(source());
|
||||
expect(run([]).status).toBe(0);
|
||||
@@ -754,7 +766,7 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
||||
// Still remote: the staged page counts as unchanged, as before.
|
||||
expect(run(["--probe"]).stdout).toMatch(/Unchanged:\s+1/);
|
||||
|
||||
gbrainConfig({ engine: "pglite" });
|
||||
gbrainConfig(PG);
|
||||
claudeJson(local);
|
||||
const probe = run(["--probe"]);
|
||||
expect(probe.stdout).toMatch(/New \(never ingested\):\s+1/);
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('setup-gbrain owned Path 4 fixture', () => {
|
||||
expect(registration.exitCode).toBe(0);
|
||||
const after = await command(detectPath, [], env);
|
||||
expect(after.exitCode).toBe(0);
|
||||
expect(JSON.parse(after.stdout).gbrain_mcp_mode).toBe('local-stdio');
|
||||
expect(JSON.parse(after.stdout).gbrain_mcp_mode).toBe('remote-http');
|
||||
expect(JSON.parse(after.stdout).gbrain_engine).toBe('pglite');
|
||||
expect(JSON.parse(after.stdout).gbrain_local_status).toBe('ok');
|
||||
expect((await command(path.join(fixture.bin, 'claude'), ['mcp', 'remove', 'gbrain'], env)).exitCode).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user