mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
`jobs list|get` have had remote MCP routing since v0.32, but the CLI shell still ran connectEngine() before dispatch — on a thin-client install that fabricates an empty scratch PGLite in the thin-client GBRAIN_HOME and replays the entire migration chain on every invocation, before the remote call even runs. Host-only jobs subcommands (work, supervisor, submit, ...) and `config` did the same instead of refusing. - cli.ts: dispatch thin-client `jobs list|get` engine-free (runJobs(null, ...)); refuse the other jobs subcommands with a pinpoint hint; add `config` to THIN_CLIENT_REFUSED_COMMANDS with a hint (it reads/writes the host brain's config plane). - jobs.ts: widen runJobs to accept a null engine, guarded so null can only reach the MCP-routed list/get branches. - tests: behavioral (no scratch store created, no migration replay, refusals carry hints) + source-audit pins in the existing idioms. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
176 lines
7.8 KiB
TypeScript
176 lines
7.8 KiB
TypeScript
/**
|
|
* v0.32 — thin-client routing audit regression guard.
|
|
*
|
|
* The v0.32 audit (eng-review D3 + Codex round 2 #4) classified every
|
|
* `case '...'` in src/cli.ts's dispatch switch as one of:
|
|
* - already routed (4 commands)
|
|
* - already refused (14 commands)
|
|
* - CLI-local (24 commands)
|
|
* - route fix added (recall, forget, jobs list/get)
|
|
* - REFUSE added (7 commands: pages, files, eval, code-def, code-refs,
|
|
* code-callers, code-callees)
|
|
*
|
|
* This test pins the REFUSE additions. A future refactor that drops one of
|
|
* these from THIN_CLIENT_REFUSED_COMMANDS would silently re-introduce the
|
|
* silent-empty-results bug class v0.31.1 was fixing.
|
|
*
|
|
* The deeper transport-mock routing tests (recall/forget/jobs actually call
|
|
* callRemoteTool with the right params) live in a serial test follow-up; the
|
|
* structural invariants pinned here catch the most common regression mode.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const CLI_TS_PATH = join(import.meta.dir, '..', 'src', 'cli.ts');
|
|
const CLI_SOURCE = readFileSync(CLI_TS_PATH, 'utf8');
|
|
|
|
// Codex round 2 #4 + audit table: every member of this list must be in
|
|
// THIN_CLIENT_REFUSED_COMMANDS and have a hint in THIN_CLIENT_REFUSE_HINTS.
|
|
// Justifications:
|
|
// - pages: pages.purge-deleted is admin+localOnly (operations.ts:856-864)
|
|
// - files: file_list + file_url MCP ops are localOnly:true
|
|
// - eval: export/prune/replay touch local engine; no MCP equivalent
|
|
// - code-def / code-refs / code-callers / code-callees: NO MCP ops exist
|
|
const V032_REFUSE_ADDITIONS = [
|
|
'pages', 'files', 'eval',
|
|
'code-def', 'code-refs', 'code-callers', 'code-callees',
|
|
];
|
|
|
|
describe('thin-client routing audit — v0.32 REFUSE additions stay in the table', () => {
|
|
test('THIN_CLIENT_REFUSED_COMMANDS set declaration is intact', () => {
|
|
expect(CLI_SOURCE).toContain('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
|
|
});
|
|
|
|
for (const command of V032_REFUSE_ADDITIONS) {
|
|
test(`'${command}' is in THIN_CLIENT_REFUSED_COMMANDS`, () => {
|
|
// We look for the literal in the set declaration. The set is plain
|
|
// text in src/cli.ts so a simple string check is honest: a future
|
|
// refactor that drops the entry would also drop the literal.
|
|
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
|
|
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
|
|
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
|
|
expect(setBlock).toContain(`'${command}'`);
|
|
});
|
|
|
|
test(`'${command}' has a hint in THIN_CLIENT_REFUSE_HINTS`, () => {
|
|
const hintsStart = CLI_SOURCE.indexOf(
|
|
'const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {',
|
|
);
|
|
const hintsEnd = CLI_SOURCE.indexOf('};', hintsStart);
|
|
const hintsBlock = CLI_SOURCE.slice(hintsStart, hintsEnd);
|
|
// Hint keys with embedded dashes are quoted (`'code-def':`); others
|
|
// can be bare (`pages:`). Accept either shape.
|
|
const bareKey = new RegExp(`\\b${command.replace(/-/g, '\\-')}\\s*:`);
|
|
const quotedKey = new RegExp(`['"]${command.replace(/-/g, '\\-')}['"]\\s*:`);
|
|
expect(bareKey.test(hintsBlock) || quotedKey.test(hintsBlock)).toBe(true);
|
|
});
|
|
}
|
|
|
|
test('every v0.31.1-era REFUSED command is still in the set (no accidental removals)', () => {
|
|
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
|
|
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
|
|
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
|
|
const v0_31_originals = [
|
|
'sync', 'embed', 'extract', 'migrate', 'apply-migrations',
|
|
'repair-jsonb', 'orphans', 'integrity', 'serve',
|
|
'dream', 'transcripts', 'storage', 'takes', 'sources',
|
|
];
|
|
for (const cmd of v0_31_originals) {
|
|
expect(setBlock).toContain(`'${cmd}'`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('thin-client routing audit — v0.32 ROUTE additions wire callRemoteTool', () => {
|
|
// The route additions are: recall, forget (in recall.ts) + jobs list / get
|
|
// (in jobs.ts). Each file must import callRemoteTool from mcp-client AND
|
|
// call it at least once. If a future refactor removes the import without
|
|
// removing the routing path, the call would fail at runtime — easier to
|
|
// catch at the source-string level.
|
|
|
|
test('src/commands/recall.ts imports callRemoteTool + isThinClient', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
|
|
'utf8',
|
|
);
|
|
expect(src).toContain(`from '../core/config.ts'`);
|
|
expect(src).toContain('isThinClient');
|
|
expect(src).toContain(`from '../core/mcp-client.ts'`);
|
|
expect(src).toContain('callRemoteTool');
|
|
});
|
|
|
|
test('src/commands/recall.ts: recall routing branch calls callRemoteTool with op="recall"', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
|
|
'utf8',
|
|
);
|
|
expect(src).toContain(`callRemoteTool(cfg!, 'recall'`);
|
|
});
|
|
|
|
test('src/commands/recall.ts: forget routing branch calls callRemoteTool with op="forget_fact"', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
|
|
'utf8',
|
|
);
|
|
expect(src).toContain(`callRemoteTool(cfg!, 'forget_fact'`);
|
|
});
|
|
|
|
test('src/commands/jobs.ts: list/get routing branches call callRemoteTool', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dir, '..', 'src', 'commands', 'jobs.ts'),
|
|
'utf8',
|
|
);
|
|
expect(src).toContain(`from '../core/mcp-client.ts'`);
|
|
expect(src).toContain(`callRemoteTool(cfg!, 'list_jobs'`);
|
|
expect(src).toContain(`callRemoteTool(cfg!, 'get_job'`);
|
|
});
|
|
});
|
|
|
|
describe('thin-client routing audit — scratch-DB additions (jobs partial dispatch + config refusal)', () => {
|
|
// `jobs list|get` route over MCP but the CLI shell still connected a
|
|
// local engine first, fabricating an empty scratch PGLite in the
|
|
// thin-client GBRAIN_HOME and replaying the full migration chain on
|
|
// every invocation. `config` did the same with no remote path at all.
|
|
|
|
test('cli.ts dispatches thin-client jobs list/get engine-free (runJobs(null, ...))', () => {
|
|
expect(CLI_SOURCE).toMatch(/command === 'jobs'/);
|
|
expect(CLI_SOURCE).toMatch(/runJobs\(null, args\)/);
|
|
});
|
|
|
|
test('cli.ts refuses non-routable jobs subcommands on thin clients via refuseThinClient', () => {
|
|
const dispatchStart = CLI_SOURCE.indexOf("if (command === 'jobs') {");
|
|
expect(dispatchStart).toBeGreaterThan(-1);
|
|
const dispatchBlock = CLI_SOURCE.slice(dispatchStart, dispatchStart + 900);
|
|
expect(dispatchBlock).toContain('isThinClient');
|
|
expect(dispatchBlock).toContain("refuseThinClient('jobs'");
|
|
});
|
|
|
|
test("'config' is in THIN_CLIENT_REFUSED_COMMANDS with a hint", () => {
|
|
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
|
|
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
|
|
expect(CLI_SOURCE.slice(setStart, setEnd)).toContain("'config'");
|
|
const hintsStart = CLI_SOURCE.indexOf(
|
|
'const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {',
|
|
);
|
|
const hintsEnd = CLI_SOURCE.indexOf('};', hintsStart);
|
|
expect(/\bconfig\s*:/.test(CLI_SOURCE.slice(hintsStart, hintsEnd))).toBe(true);
|
|
});
|
|
|
|
test("'jobs' is NOT in THIN_CLIENT_REFUSED_COMMANDS (partial dispatch owns it)", () => {
|
|
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
|
|
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
|
|
expect(CLI_SOURCE.slice(setStart, setEnd)).not.toContain("'jobs'");
|
|
});
|
|
|
|
test('jobs.ts guards the null-engine path to list/get only', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dir, '..', 'src', 'commands', 'jobs.ts'),
|
|
'utf8',
|
|
);
|
|
expect(src).toContain('engineOrNull: BrainEngine | null');
|
|
expect(src).toMatch(/if \(!engineOrNull && sub !== 'list' && sub !== 'get'\)/);
|
|
});
|
|
});
|