mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* v0.31.1 feat: get_brain_identity MCP op (Issue #734 prep) Lightweight read-scope op that returns {version, engine, page_count, chunk_count, last_sync_iso} for the thin-client identity banner. Reuses engine.getStats() — banner's 60s TTL cache (next commit) bounds frequency to ≤1/60s per CLI process. Banner-only op, no cliHints. Pinned by 9 tests in test/get-brain-identity.test.ts. Part of v0.31.1 fix for #734 (thin-client mode silently routing ~25 CLI commands to empty local PGLite). See plan at ~/.claude/plans/how-to-make-mcp-iterative-liskov.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: harden callRemoteTool error normalization + abort/timeout CDX-4 (Codex outside-voice finding): the previous callRemoteTool let plain Error escape — undici network errors, AbortError, JSON parse failures all bubbled untyped. Plan called for an exhaustive switch on RemoteMcpError.reason at the dispatcher; that contract was unsound. Hardening: - New CallRemoteToolOptions {timeoutMs?, signal?} (4th arg, optional). - buildAbortController composes external signal with timeout into a single signal threaded through the SDK transport's requestInit. - toRemoteMcpError funnel converts ANY thrown value to RemoteMcpError before re-raising; the outermost try/catch guarantees the contract. - RemoteMcpErrorReason exported as a stable union type. - RemoteMcpErrorDetail.kind ('timeout'|'aborted'|'unreachable') sub-tags network errors so the dispatcher can render the right hint. - RemoteMcpErrorDetail.code carries server-supplied error codes on tool_error (e.g. 'missing_scope') for pinpoint refusal hints. - extractToolErrorCode parses JSON envelopes first, falls back to substring detection for legacy server messages. All 13 existing mcp-client tests still pass. Typecheck clean. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: --timeout=Ns CLI flag for thin-client routed calls (ENG-4) New global flag --timeout that accepts ms / s / m / ms-suffix forms ("30s", "2m", "500ms", "500"). Default null = per-command default (30s for most ops, 180s for `think` per ENG-4). Plumbs through to callRemoteTool's AbortController via cliOpts.timeoutMs. Rejection cases (timeoutMs stays null, flag falls through): - --timeout=0 (must be positive) - --timeout=garbage (no parse) Pinned by 8 new tests in test/cli-options.test.ts (total 28 pass). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: thin-client routing seam in cli.ts (CDX-1) The keystone fix for Issue #734. Inserts the routing seam INSIDE the existing op-dispatch path in cli.ts:78-138 (per Codex finding CDX-1) — no parallel `src/core/thin-client/` module. Routing is a ~80-line conditional that runs BEFORE connectEngine() so thin-client installs never open the empty local PGLite. Architecture (CDX-1, CDX-4, ENG-2, ENG-4): - Existing arg parser, image-to-base64 transform, stdin handler, and required-param check run UNCHANGED before the routing branch. Zero duplicated parsers. - New runThinClientRouted(op, params, cfg, cliOpts) calls callRemoteTool with {timeoutMs, signal}; default 180s for `think`, 30s otherwise; --timeout flag overrides. - SIGINT abort threaded into AbortController → exit 130. - Exhaustive TS `never` switch on RemoteMcpError.reason produces canned, actionable user messages per failure mode (ENG-4 contract). - ENG-2 renderer parity: local-engine path runs JSON.parse(JSON.stringify()) on the result before formatResult, killing the Date/bigint/Buffer drift class without per-command renderer audit. - THIN_CLIENT_REFUSE_HINTS table replaces the generic refusal message with pinpoint hints (CDX-5 / cherry-pick A). Adds dream/transcripts/storage to the refused set with their own hints. - localOnly ops on thin-client refuse via refuseThinClient (with hint). Pinned by 14 cli-dispatch-thin-client tests (all pass). Typecheck clean. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: thin-client identity banner (cherry-pick B) Prints "[thin-client → wintermute.fly.dev:3131 · brain: 102k pages, 265k chunks · v0.31.1]" to stderr before each routed command. Kills the "am I empty?" confusion that drove the original Hermes/Neuromancer report against wintermute (102k pages → empty CLI search results). Cache: 60s TTL, in-memory Map keyed by mcp_url so switching hosts via `gbrain init` invalidates cleanly. Cross-process file cache deferred. Suppression: --quiet, GBRAIN_NO_BANNER=1, non-TTY default suppresses unless GBRAIN_BANNER=1 explicitly opts in (clean pipes for shell flows). Failure mode: banner fetch errors swallowed; underlying command runs normally. Banner is observability, never load-bearing. The hardened callRemoteTool will surface the same error class on the actual call if the host is genuinely unreachable. Inline in cli.ts per CDX-1 (no parallel module). _clearIdentityCacheForTest exported as test escape hatch. Backed by the new `get_brain_identity` MCP op (read-scope, banner-only). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: route CLI-only commands with MCP equivalents (salience/anomalies/graph-query/think) These four CLI commands bypass the operation-layer dispatch and call engine methods directly today, so the cli.ts routing seam doesn't catch them. Each gets a thin per-command branch: when isThinClient(cfg), callRemoteTool against the corresponding op; otherwise existing engine path runs unchanged. Mappings: - gbrain salience → get_recent_salience (read scope, 30s timeout) - gbrain anomalies → find_anomalies (read scope, 30s timeout) - gbrain graph-query → traverse_graph (read scope, 30s timeout) - gbrain think → think (write scope, 180s timeout) `think` is a special case: the server's think op intentionally disables --save/--take for remote callers (operations.ts:1103-1135 trust-boundary gate per CLAUDE.md subagent-isolation policy). Thin-client think prints a loud warning when those flags are set so users know what they lose instead of silent ignoring. Documented as v0.31.x policy review in plan. Output format unchanged on both paths — the MCP op handler IS the engine method, so the unpacked tool result has identical shape. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: oauth_client_scopes_probe doctor check (CDX-5) \`gbrain remote doctor\` gains a 5th check that probes the read + admin scope tiers via two harmless read-only MCP calls (get_brain_identity and get_health). Surfaces v0.29.2/v0.30.0 thin-client clients that registered with read+write only and now hit \`gbrain stats\` / \`gbrain history\` and fail mid-flight — instead of failing mid-command, doctor names the exact remediation: On the host: gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin Status semantics (informational by default): - read.missing_scope → fail (broken setup) - admin.missing_scope → warn + pinpoint hint (the load-bearing case) - both succeed → ok - non-scope probe errors (parse/network/timeout) → ok with detail.inconclusive=true (doctor's overall status doesn't flap) GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 env-flag for test fixtures that mock /mcp at JSON-RPC initialize level only (MCP SDK Client hangs on shape mismatch and doesn't always honor AbortSignal — adversarial test behavior we don't want to bake into doctor). Pinned by 8 cases in test/oauth-scope-probe.test.ts (pure-function buildScopeCheck) plus unchanged passing of all 23 doctor-remote tests. CDX-5 from the codex outside-voice review. Keeps host-side \`gbrain auth register-client\` default at \`read\` (no breaking change for existing scrapers); puts the migration burden on the THIN-CLIENT side where it belongs. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: refuse \`takes\`/\`sources\` on thin-client with MCP-tool hints (CDX-2) Per the CDX-2 op-coverage audit: takes and sources are multi-subcommand CLIs with mixed local/routable surface. Their READ subcommands (takes_list, takes_search, sources_list, sources_status) have MCP equivalents — those land in v0.31.x with per-subcommand splits. For v0.31.1, refuse both at the top level with hints naming the MCP tools so agents know exactly which tools to invoke directly. Honest framing per CDX-2: "thin-client gbrain routes the read+write+admin op surface; multi-subcommand CLIs land incrementally." Per-subcommand routing recorded as v0.31.x TODO in the plan. Storage is also refused (filesystem-bound; no remote equivalent). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 docs + version: bump VERSION/package.json, CHANGELOG, TODOS, CLAUDE.md Cross-cut for v0.31.1 ship: - VERSION: 0.30.0 → 0.31.1 - package.json: "version": "0.31.1" (bun install refreshed bun.lock) - CHANGELOG.md: full release-summary entry per CLAUDE.md voice contract (numbers-that-matter table with before/after comparison, what-this-means closer, take-advantage block with exact remediation commands, itemized changes by surface, contributor section with plan/decision-history pointer) - TODOS.md: 7 follow-up entries for v0.31.x (timing telemetry, job-routing, per-subcommand takes/sources split, transcripts privacy decision, trust-boundary policy review, register-client default flip, cross-process token cache, parity test backfill) - CLAUDE.md: new "Thin-client routing" section under "Key files" annotating every changed/new file with its v0.31.1 contract — src/cli.ts routing seam, src/core/mcp-client.ts hardening, src/core/cli-options.ts --timeout, src/core/doctor-remote.ts scope-probe, get_brain_identity op, per-command routing in salience/anomalies/graph-query/think. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 fix: collectRemoteDoctorReport opts.skipScopeProbe + regen llms.txt Replaces the env-var GBRAIN_DOCTOR_SKIP_SCOPE_PROBE module-mutation in test/doctor-remote.test.ts with an explicit opts arg threaded through collectRemoteDoctorReport(config, opts). Satisfies the test-isolation lint (rule R1: no process.env.X = ... in non-serial unit files). Production callers still honor the env-flag for ops bypass; opts wins when both are set. Also regenerates llms.txt + llms-full.txt to match the v0.31.1 CLAUDE.md additions (build:llms drift check passes). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 test: close coverage gaps — issue #734 e2e regression + CDX-4 hardening unit tests Two real gaps the prior coverage missed: 1. **Issue #734 regression e2e** (test/e2e/thin-client.test.ts +6 cases): Existing e2e covered init/doctor/sync-refusal/remote-ping/no-admin but never exercised the actual bug — `gbrain search` against a populated host. Added the load-bearing regression: seed two pages on the host, run thin-client `gbrain search "<unique-token>"`, assert non-zero rows AND seeded slug present in stdout. If this assertion ever fails, #734 has regressed. Plus: routed identity banner verification (GBRAIN_BANNER=1 path), --quiet suppression check, routed put round-trip (write reaches host, visible from host's local engine), routed admin stats (page_count > 0 not 0/0), and pinpoint refuse-hint format for `gbrain sync`. 2. **CDX-4 hardening unit tests** (test/mcp-client-hardening.test.ts +31 cases): pre-fix the hardening pass had ZERO direct unit coverage. The "exhaustive switch on RemoteMcpError.reason" promise depended on toRemoteMcpError actually normalizing every thrown value, but nothing verified that contract. Added: - toRemoteMcpError: passthrough for RemoteMcpError, AbortError → network/aborted, plain Error → network/unreachable, string/object/null non-Error throwables → network/unreachable, mcp_url always populated, contract test that EVERY output has a recognized reason - extractToolErrorCode: JSON envelope (error.code + top-level code), substring fallback for missing-scope-shaped messages, defensive handling of non-string code field, malformed-JSON fallthrough - buildAbortController: timeout fires on schedule, external signal propagates immediately when pre-aborted and lazily when aborted later, timeout + external compose (whichever fires first wins), cleanup is idempotent and removes external listener (no leak) - RemoteMcpError class shape (instanceof Error, reason/detail readonly, name="RemoteMcpError", detail optional) - CallRemoteToolOptions type contract Internal helpers (toRemoteMcpError, extractToolErrorCode, buildAbortController) gain @internal export tags so the test file can import them without going through the SDK transport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 test: move routing tests before remote-ping; fix pre-existing assertion for new refusal format The newly-added routing tests were running AFTER `gbrain remote ping`, which submits a 60s autopilot-cycle and can leave the server in a state where subsequent OAuth probes fail. Moving them before Tier B so they exercise a healthy server. Also updated the existing `sync is refused with canonical thin-client error` test assertion: v0.31.1 changed the refusal format from generic \`thin client\` (with space) to the pinpoint \`thin-client of <url>\` (with hyphen) plus \`not routable\` prefix. The test now asserts both the new format and the pinpoint hint. E2E result: 10 pass / 3 fail. The 3 failures are pre-existing on master (remote-ping timeout, client-without-admin OAuth discovery flake) and not in my diff scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 fix: scrub banned fork name from new test fixtures (CI privacy gate) CI's check-privacy.sh rejected the v0.31.1 test additions because the unique-token fixture string used the private OpenClaw fork name as a prefix. Replaced with neutral names per CLAUDE.md privacy rule: - test/e2e/thin-client.test.ts: \`wintermute_routing_proof\` → \`host_routing_proof\` (the unique-token marker that proves search results came from the remote brain, not the empty local PGLite). All 6 references updated. - test/mcp-client-hardening.test.ts: \`https://wintermute.fly.dev/mcp\` → \`https://brain-host.example/mcp\` (the synthetic MCP URL used as the toRemoteMcpError second arg). Matches the convention used in the existing test/cli-dispatch-thin-client.test.ts fixture. bun run verify passes; 31/31 hardening tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
/**
|
|
* E2E test for thin-client mode (multi-topology v1).
|
|
*
|
|
* Spins up `gbrain serve --http` against a real Postgres, registers a
|
|
* client with `read,write,admin` scope, runs `gbrain init --mcp-only`
|
|
* against it from a second tempdir HOME, and exercises the canonical
|
|
* thin-client flows:
|
|
*
|
|
* - `gbrain init --mcp-only` succeeds and writes remote_mcp config
|
|
* - `gbrain doctor` reports `mode: thin-client` with all checks green
|
|
* - `gbrain sync` is refused with the canonical thin-client error
|
|
* - re-running `gbrain init` refuses without --force
|
|
*
|
|
* Tier B flows (`gbrain remote ping` / `remote doctor`) are stubbed for now
|
|
* and will be exercised when the Tier B commands ship.
|
|
*
|
|
* Skips when DATABASE_URL is unset (matches the e2e gate convention used
|
|
* across the suite).
|
|
*/
|
|
|
|
import { describe, test as testRaw, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
function test(name: string, fn: () => void | Promise<unknown>): void {
|
|
testRaw(name, fn, 120000);
|
|
}
|
|
|
|
const CLI = join(__dirname, '..', '..', 'src', 'cli.ts');
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
|
|
interface RunResult { exitCode: number; stdout: string; stderr: string; }
|
|
|
|
async function spawn(args: string[], home: string, extraEnv: Record<string, string | undefined> = {}): Promise<RunResult> {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GBRAIN_HOME = home;
|
|
delete env.GBRAIN_REMOTE_CLIENT_SECRET;
|
|
for (const [k, v] of Object.entries(extraEnv)) {
|
|
if (v === undefined) delete env[k];
|
|
else env[k] = v;
|
|
}
|
|
const proc = Bun.spawn({
|
|
cmd: ['bun', 'run', CLI, ...args],
|
|
env,
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
return { exitCode, stdout, stderr };
|
|
}
|
|
|
|
// Skip the entire suite when DATABASE_URL is unset. Same pattern as other
|
|
// E2E tests in this directory.
|
|
const describeWhen = DATABASE_URL ? describe : describe.skip;
|
|
|
|
describeWhen('thin-client end-to-end (requires DATABASE_URL)', () => {
|
|
let hostHome: string; // GBRAIN_HOME for the host (with local engine)
|
|
let clientHome: string; // GBRAIN_HOME for the thin client (no engine)
|
|
let serverProc: ReturnType<typeof Bun.spawn> | null = null;
|
|
let serverPort: number;
|
|
let clientId: string;
|
|
let clientSecret: string;
|
|
|
|
beforeAll(async () => {
|
|
hostHome = mkdtempSync(join(tmpdir(), 'gbrain-thin-host-'));
|
|
clientHome = mkdtempSync(join(tmpdir(), 'gbrain-thin-client-'));
|
|
|
|
// 1. Init host with a real Postgres.
|
|
const init = await spawn(['init', '--non-interactive', '--url', DATABASE_URL!], hostHome);
|
|
if (init.exitCode !== 0) throw new Error(`host init failed: ${init.stderr || init.stdout}`);
|
|
|
|
// 2. Pick a random free port for serve --http.
|
|
serverPort = 30000 + Math.floor(Math.random() * 30000);
|
|
|
|
// 3. Spawn serve --http (background, async).
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GBRAIN_HOME = hostHome;
|
|
serverProc = Bun.spawn({
|
|
cmd: ['bun', 'run', CLI, 'serve', '--http', '--port', String(serverPort)],
|
|
env,
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
|
|
// Wait for the server to be ready (poll the discovery endpoint).
|
|
const deadline = Date.now() + 20_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${serverPort}/.well-known/oauth-authorization-server`, {
|
|
signal: AbortSignal.timeout(500),
|
|
});
|
|
if (res.ok) break;
|
|
} catch { /* retry */ }
|
|
await new Promise(r => setTimeout(r, 250));
|
|
}
|
|
|
|
// 4. Register a client with read,write,admin scope.
|
|
const reg = await spawn([
|
|
'auth', 'register-client', 'thin-client-test',
|
|
'--grant-types', 'client_credentials',
|
|
'--scopes', 'read write admin',
|
|
], hostHome);
|
|
if (reg.exitCode !== 0) throw new Error(`register-client failed: ${reg.stderr || reg.stdout}`);
|
|
const parsed = parseRegisterClientOutput(reg.stdout);
|
|
clientId = parsed.clientId;
|
|
clientSecret = parsed.clientSecret;
|
|
if (!clientId || !clientSecret) {
|
|
throw new Error(`register-client returned unexpected output: ${reg.stdout}`);
|
|
}
|
|
});
|
|
|
|
function parseRegisterClientOutput(out: string): { clientId: string; clientSecret: string } {
|
|
// `gbrain auth register-client` doesn't have --json; parse human output:
|
|
// Client ID: <id>
|
|
// Client Secret: <secret>
|
|
const idMatch = out.match(/Client ID:\s*(\S+)/);
|
|
const secretMatch = out.match(/Client Secret:\s*(\S+)/);
|
|
return {
|
|
clientId: idMatch?.[1] ?? '',
|
|
clientSecret: secretMatch?.[1] ?? '',
|
|
};
|
|
}
|
|
|
|
afterAll(async () => {
|
|
if (serverProc) {
|
|
try { serverProc.kill(); } catch { /* best-effort */ }
|
|
try { await serverProc.exited; } catch { /* ignore */ }
|
|
}
|
|
try { rmSync(hostHome, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
try { rmSync(clientHome, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
});
|
|
|
|
test('init --mcp-only succeeds against the live host', async () => {
|
|
const r = await spawn([
|
|
'init', '--mcp-only', '--json',
|
|
'--issuer-url', `http://127.0.0.1:${serverPort}`,
|
|
'--mcp-url', `http://127.0.0.1:${serverPort}/mcp`,
|
|
'--oauth-client-id', clientId,
|
|
'--oauth-client-secret', clientSecret,
|
|
], clientHome);
|
|
expect(r.exitCode).toBe(0);
|
|
const cfgPath = join(clientHome, '.gbrain', 'config.json');
|
|
expect(existsSync(cfgPath)).toBe(true);
|
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
expect(cfg.remote_mcp.oauth_client_id).toBe(clientId);
|
|
// No PGLite file
|
|
expect(existsSync(join(clientHome, '.gbrain', 'brain.pglite'))).toBe(false);
|
|
});
|
|
|
|
test('doctor reports mode: thin-client with all checks green', async () => {
|
|
const r = await spawn(['doctor', '--json'], clientHome);
|
|
expect(r.exitCode).toBe(0);
|
|
const report = JSON.parse(r.stdout.trim());
|
|
expect(report.mode).toBe('thin-client');
|
|
expect(report.status).toBe('ok');
|
|
const checkNames = report.checks.map((c: { name: string }) => c.name);
|
|
expect(checkNames).toContain('config_integrity');
|
|
expect(checkNames).toContain('oauth_discovery');
|
|
expect(checkNames).toContain('oauth_token');
|
|
expect(checkNames).toContain('mcp_smoke');
|
|
expect(report.oauth_scope).toContain('admin');
|
|
});
|
|
|
|
test('sync is refused with canonical thin-client error', async () => {
|
|
const r = await spawn(['sync'], clientHome);
|
|
expect(r.exitCode).toBe(1);
|
|
// v0.31.1: refusal carries pinpoint hint format (`thin-client of <url>`
|
|
// with hyphen) instead of the v0.30 generic `thin client` (with space).
|
|
expect(r.stderr).toContain('thin-client of');
|
|
expect(r.stderr).toContain(`http://127.0.0.1:${serverPort}/mcp`);
|
|
expect(r.stderr).toContain('not routable');
|
|
});
|
|
|
|
test('re-running init refuses without --force', async () => {
|
|
const r = await spawn(['init', '--non-interactive', '--pglite', '--json'], clientHome);
|
|
expect(r.exitCode).toBe(1);
|
|
const parsed = JSON.parse(r.stdout.trim().split('\n').pop()!);
|
|
expect(parsed.reason).toBe('thin_client_config_present');
|
|
});
|
|
|
|
// ─── v0.31.1 (Issue #734) — routing seam regression tests ───
|
|
//
|
|
// Run BEFORE Tier B (remote ping) because the remote-ping test runs a
|
|
// 60s autopilot-cycle that can leave the server in a state where
|
|
// subsequent OAuth probes fail. Routing tests need a healthy server.
|
|
//
|
|
// The bug being fixed: thin-client gbrain commands silently fell through to
|
|
// the empty local PGLite, returned "No results." (exit 0), and never reached
|
|
// the remote brain. These tests pin the routing path against a real seeded
|
|
// host. If any of these regress, the silent-empty-results bug is back.
|
|
|
|
test('issue #734 regression: gbrain search routes to host and returns seeded rows', async () => {
|
|
// Seed two pages on the host via direct `gbrain put` (host has the engine).
|
|
// Both contain the unique token "host_routing_proof" so we can grep
|
|
// the response body to prove it came from the remote brain.
|
|
const seed1 = await spawn([
|
|
'put', 'wiki/test/routing-proof-1',
|
|
'--type', 'note',
|
|
'--title', 'Routing Proof Page One',
|
|
'--content', '# Routing Proof One\n\nUnique token: host_routing_proof. This page only exists on the host.',
|
|
], hostHome);
|
|
if (seed1.exitCode !== 0) throw new Error(`seed1 put failed: ${seed1.stderr || seed1.stdout}`);
|
|
|
|
const seed2 = await spawn([
|
|
'put', 'wiki/test/routing-proof-2',
|
|
'--type', 'note',
|
|
'--title', 'Routing Proof Page Two',
|
|
'--content', '# Routing Proof Two\n\nAnother page with host_routing_proof.',
|
|
], hostHome);
|
|
if (seed2.exitCode !== 0) throw new Error(`seed2 put failed: ${seed2.stderr || seed2.stdout}`);
|
|
|
|
// Now run search from the THIN CLIENT. Pre-v0.31.1 this returned
|
|
// "No results." against the empty local PGLite. v0.31.1 routes via MCP
|
|
// and must return at least one row referencing the seeded slug.
|
|
const r = await spawn(['search', 'host_routing_proof'], clientHome);
|
|
|
|
// Hard-fail conditions that pin the bug fix:
|
|
expect(r.exitCode).toBe(0);
|
|
// The original bug: empty stdout. If this assertion ever fails, #734 has
|
|
// regressed — silent-empty-results is back.
|
|
expect(r.stdout.length).toBeGreaterThan(0);
|
|
expect(r.stdout).not.toContain('No results.');
|
|
// Seeded slug must appear in the routed response body.
|
|
expect(r.stdout).toContain('wiki/test/routing-proof');
|
|
});
|
|
|
|
test('routed search emits identity banner on stderr (cherry-pick B)', async () => {
|
|
// Run search with stderr captured. Banner is suppressed in non-TTY by
|
|
// default per our suppression rules; opt back in with GBRAIN_BANNER=1.
|
|
const r = await spawn(['search', 'host_routing_proof'], clientHome, {
|
|
GBRAIN_BANNER: '1',
|
|
});
|
|
expect(r.exitCode).toBe(0);
|
|
// Banner format: [thin-client → host:port · brain: Npages, Nchunks · vX.Y.Z]
|
|
expect(r.stderr).toContain('thin-client');
|
|
expect(r.stderr).toContain(`127.0.0.1:${serverPort}`);
|
|
expect(r.stderr).toMatch(/v\d+\.\d+\.\d+/);
|
|
});
|
|
|
|
test('--quiet suppresses banner even with GBRAIN_BANNER=1', async () => {
|
|
// --quiet wins over GBRAIN_BANNER=1. Belt-and-suspenders for shell pipelines.
|
|
const r = await spawn(['--quiet', 'search', 'host_routing_proof'], clientHome, {
|
|
GBRAIN_BANNER: '1',
|
|
});
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stderr).not.toContain('thin-client →');
|
|
});
|
|
|
|
test('routed put round-trip: thin-client write reaches the host', async () => {
|
|
// Write from the thin client. Pre-v0.31.1 this would have hit the empty
|
|
// local PGLite; v0.31.1 routes through MCP put_page.
|
|
const w = await spawn([
|
|
'put', 'wiki/test/thin-client-write-proof',
|
|
'--type', 'note',
|
|
'--title', 'Written By Thin Client',
|
|
'--content', '# Written By Thin Client\n\nThis page was created via routed MCP put.',
|
|
], clientHome);
|
|
expect(w.exitCode).toBe(0);
|
|
|
|
// Verify it landed on the host by reading it back from the host's local engine.
|
|
const r = await spawn(['get', 'wiki/test/thin-client-write-proof'], hostHome);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toContain('Written By Thin Client');
|
|
expect(r.stdout).toContain('routed MCP put');
|
|
});
|
|
|
|
test('routed stats: admin-scope client returns real numbers (not 0/0)', async () => {
|
|
// Pre-v0.31.1: thin-client `gbrain stats` returned page_count=0 against
|
|
// empty local PGLite. v0.31.1 routes to host's get_stats op (admin scope)
|
|
// and surfaces real numbers. We seeded 2+ pages above — page_count must
|
|
// reflect that, not zero.
|
|
const r = await spawn(['stats', '--json'], clientHome);
|
|
expect(r.exitCode).toBe(0);
|
|
const stats = JSON.parse(r.stdout.trim());
|
|
expect(stats.page_count).toBeGreaterThan(0);
|
|
expect(stats.chunk_count).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('local-only command refused with pinpoint hint (sync)', async () => {
|
|
// Refusal is already covered upstream but this pins the v0.31.1 hint
|
|
// format ("not routable. <hint>") instead of the v0.30 generic message.
|
|
const r = await spawn(['sync'], clientHome);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain('not routable');
|
|
expect(r.stderr).toContain('gbrain remote ping');
|
|
});
|
|
|
|
// ─── Tier B: gbrain remote ping + remote doctor ───
|
|
|
|
test('gbrain remote doctor returns the host DoctorReport', async () => {
|
|
const r = await spawn(['remote', 'doctor', '--json'], clientHome);
|
|
// Exit code reflects the host brain's health. On an empty fresh brain
|
|
// brain_score is 0, so status is 'unhealthy' and exit is 1. That's
|
|
// legitimate doctor output, not a transport failure. What this test
|
|
// pins is the round-trip + JSON shape.
|
|
const report = JSON.parse(r.stdout.trim());
|
|
expect(report.schema_version).toBe(2);
|
|
expect(['healthy', 'warnings', 'unhealthy']).toContain(report.status);
|
|
const names = report.checks.map((c: { name: string }) => c.name);
|
|
expect(names).toContain('connection');
|
|
expect(names).toContain('schema_version');
|
|
expect(names).toContain('brain_score');
|
|
expect(names).toContain('queue_health');
|
|
// Host is fresh + connected, so connection check is OK.
|
|
const conn = report.checks.find((c: { name: string; status: string }) => c.name === 'connection');
|
|
expect(conn.status).toBe('ok');
|
|
// Schema version is at LATEST_VERSION on a fresh init.
|
|
const sv = report.checks.find((c: { name: string; status: string }) => c.name === 'schema_version');
|
|
expect(sv.status).toBe('ok');
|
|
});
|
|
|
|
test('gbrain remote ping triggers autopilot-cycle and returns terminal state', async () => {
|
|
// Test budget: 60s ping wait, 120s test timeout (overhead). Empty brain
|
|
// with no configured repo path will likely have autopilot-cycle fail-fast
|
|
// in the sync phase — that's fine. What this test pins is the wire path:
|
|
// submit_job → get_job poll → terminal state JSON. NOT cycle success on
|
|
// a no-repo fixture.
|
|
const r = await spawn(['remote', 'ping', '--json', '--timeout', '60s'], clientHome);
|
|
expect(r.stdout.length).toBeGreaterThan(0);
|
|
const parsed = JSON.parse(r.stdout.trim());
|
|
expect(parsed).toHaveProperty('job_id');
|
|
expect(parsed.job_id).toBeGreaterThan(0);
|
|
// success → completed; otherwise any terminal state OR timeout is OK.
|
|
if (parsed.status === 'success') {
|
|
expect(parsed.state).toBe('completed');
|
|
} else {
|
|
expect(['failed', 'dead', 'cancelled', 'timeout']).toContain(parsed.reason ?? parsed.state);
|
|
}
|
|
});
|
|
|
|
test('client without admin scope cannot call run_doctor', async () => {
|
|
// Register a separate client with read+write only (no admin) and verify
|
|
// that gbrain remote doctor surfaces an auth-error message. This is the
|
|
// codex review #7 regression guard — the verification flow MUST require
|
|
// admin scope.
|
|
const reg = await spawn([
|
|
'auth', 'register-client', 'thin-client-readwrite',
|
|
'--grant-types', 'client_credentials',
|
|
'--scopes', 'read write',
|
|
], hostHome);
|
|
if (reg.exitCode !== 0) throw new Error(`register-client failed: ${reg.stderr || reg.stdout}`);
|
|
const parsed = parseRegisterClientOutput(reg.stdout);
|
|
const lowScopeId = parsed.clientId;
|
|
const lowScopeSecret = parsed.clientSecret;
|
|
|
|
// Spin up a separate clientHome for the lower-scope client
|
|
const lowScopeHome = mkdtempSync(join(tmpdir(), 'gbrain-thin-client-lowscope-'));
|
|
try {
|
|
const init = await spawn([
|
|
'init', '--mcp-only', '--json',
|
|
'--issuer-url', `http://127.0.0.1:${serverPort}`,
|
|
'--mcp-url', `http://127.0.0.1:${serverPort}/mcp`,
|
|
'--oauth-client-id', lowScopeId,
|
|
'--oauth-client-secret', lowScopeSecret,
|
|
], lowScopeHome);
|
|
if (init.exitCode !== 0) {
|
|
throw new Error(`low-scope init exit=${init.exitCode}\nstdout:${init.stdout}\nstderr:${init.stderr}`);
|
|
}
|
|
expect(init.exitCode).toBe(0);
|
|
|
|
const r = await spawn(['remote', 'doctor', '--json'], lowScopeHome);
|
|
expect(r.exitCode).toBe(1);
|
|
const err = JSON.parse(r.stdout.trim());
|
|
expect(err.status).toBe('error');
|
|
// Either the SDK 401 path or our auth_after_refresh wrap is fine —
|
|
// the test pins "this fails because admin scope is missing".
|
|
expect(['auth', 'auth_after_refresh', 'tool_error']).toContain(err.reason);
|
|
} finally {
|
|
rmSync(lowScopeHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|