mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 03:00:26 +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>
This commit is contained in:
1 parent
c830c9b8ba
commit
f371261895
4 files changed
+618
No files matched your search
@@ -760,6 +760,38 @@ describe("lib/gbrain-local-status — bearer-token thin-client (#2520)", () => {
|
|||||||
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
|
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 'engine-locked' for a loopback http gbrain MCP over the local 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("engine-locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
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)", () => {
|
it("still returns 'missing-config' when no gbrain MCP registration exists (discriminator)", () => {
|
||||||
env = makeEnv({
|
env = makeEnv({
|
||||||
withGbrain: true,
|
withGbrain: true,
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* #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,
|
||||||
|
hasLocalGbrainEngine,
|
||||||
|
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;
|
||||||
|
|
||||||
|
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:pw@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('hasLocalGbrainEngine', () => {
|
||||||
|
test('pglite engine, or a database_url, with no remote_mcp marker', () => {
|
||||||
|
const env = { HOME: home };
|
||||||
|
expect(hasLocalGbrainEngine(env)).toBe(false); // no config
|
||||||
|
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);
|
||||||
|
writeGbrainConfig({ database_url: ' ' });
|
||||||
|
expect(hasLocalGbrainEngine(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);
|
||||||
|
}
|
||||||
|
for (const remote_mcp of [{}, [], 'x', 1, true]) {
|
||||||
|
writeGbrainConfig({ engine: 'pglite', remote_mcp });
|
||||||
|
expect(hasLocalGbrainEngine(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
||||||
|
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' } });
|
||||||
|
expect(classifyGbrainMcpEntry(loopback, { HOME: home })).toBe('remote');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-loopback http entry stays remote even with a local engine', () => {
|
||||||
|
writeGbrainConfig({ engine: 'pglite' });
|
||||||
|
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({ engine: 'pglite' }));
|
||||||
|
|
||||||
|
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'] });
|
||||||
|
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:pw@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' },
|
||||||
|
{ database_url: 'postgresql://u@localhost:5432/brain' },
|
||||||
|
{ 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 = hasLocalGbrainEngine({ 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(hasLocalGbrainEngine({ 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 engine is not remote-mode', () => {
|
||||||
|
expect(artifactsSyncLine({ type: 'http', url: 'http://127.0.0.1:3131/mcp' }, { engine: 'pglite' })).toBe('ARTIFACTS_SYNC: off');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
).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' } }),
|
||||||
|
).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(
|
||||||
|
/^ARTIFACTS_SYNC: remote-mode \(managed by brain server brain\.example\.com\)$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -266,6 +266,151 @@ 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: 'pglite', ...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: 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 } } }));
|
||||||
|
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 ;;',
|
||||||
|
' *) exit 0 ;;',
|
||||||
|
'esac',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
const { json } = runDetect();
|
||||||
|
expect(json.gbrain_mcp_mode).toBe('local-stdio');
|
||||||
|
expect(json.gbrain_local_status).toBe('engine-locked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('gbrain_mcp_mode — no info anywhere', () => {
|
describe('gbrain_mcp_mode — no info anywhere', () => {
|
||||||
test('no claude binary AND no ~/.claude.json → none', () => {
|
test('no claude binary AND no ~/.claude.json → none', () => {
|
||||||
// No fake claude, no file.
|
// No fake claude, no file.
|
||||||
|
|||||||
@@ -685,6 +685,89 @@ process.stdout.write(execFileSync(process.execPath, ['-e', 'process.stdout.write
|
|||||||
expect(staged[0]).not.toContain("_unattributed");
|
expect(staged[0]).not.toContain("_unattributed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a loopback gbrain serve --http over the local engine is a local brain (#3539)", () => {
|
||||||
|
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({ engine: "pglite", database_path: join(home, ".gbrain", "brain.pglite") });
|
||||||
|
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({ engine: "pglite" });
|
||||||
|
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 when gbrain's config carries remote_mcp", () => {
|
||||||
|
scanner("clean");
|
||||||
|
gbrainConfig({ engine: "pglite", 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({ engine: "pglite" });
|
||||||
|
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", () => {
|
it("scans remote-http pages before persistent staging", () => {
|
||||||
scanner("clean");
|
scanner("clean");
|
||||||
writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { gbrain: { type: "http", url: "http://fixture.invalid/mcp" } } }));
|
writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { gbrain: { type: "http", url: "http://fixture.invalid/mcp" } } }));
|
||||||
|
|||||||
Reference in new issue
Block a user