Files
gbrain/test/e2e/thin-client.test.ts
T
8392d434a9 v0.29.2 feat: thin-client mode (gbrain init --mcp-only + gbrain remote ping/doctor + topologies) (#732)
* feat(config): add remote_mcp field + isThinClient() helper

Adds a top-level optional remote_mcp config block to GBrainConfig
(issuer_url, mcp_url, oauth_client_id, oauth_client_secret) for
thin-client installs that consume a remote `gbrain serve --http` over
MCP instead of running a local engine.

isThinClient(config) returns true when remote_mcp is set; used by the
CLI dispatch guard, doctor branch, and init re-run guard. The engine
field stays as today (postgres|pglite); thin-client mode is a separate
config field, NOT an engine kind extension (codex outside-voice review
flagged the engine='remote' extension as overreach).

GBRAIN_REMOTE_CLIENT_SECRET env var overrides the config-file value at
load time so the secret can stay out of disk for headless agents.

Foundation commit for multi-topology v1; no behavior change yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(probe): outbound OAuth + MCP smoke probes

Adds three pure async functions over the standard fetch API:
  - discoverOAuth(issuerUrl): GET /.well-known/oauth-authorization-server
  - mintClientCredentialsToken(tokenEndpoint, id, secret): POST /token
  - smokeTestMcp(mcpUrl, accessToken): POST /mcp initialize

Discriminated 'ok=true' / 'ok=false + reason' return shapes so callers
render error messages consistently. No SDK dependency to keep init's
setup-flow scope tight; Lane B's mcp-client.ts will pull in the
official @modelcontextprotocol/sdk Client for full session semantics.

Used by both 'gbrain init --mcp-only' (Lane A's setup smoke) and
runRemoteDoctor (Lane A's thin-client doctor checks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(init): --mcp-only branch + re-run guard

Adds 'gbrain init --mcp-only' for thin-client setup. Required flags
(or env vars):
  --issuer-url     OAuth root (e.g. https://host:3001)
  --mcp-url        MCP tool dispatch path (e.g. https://host:3001/mcp)
  --oauth-client-id, --oauth-client-secret

Pre-flight runs three smoke probes (discovery, token round-trip, MCP
initialize) BEFORE writing the config — fail-fast on bad URL beats
fail-late on bad credentials. On success, writes ~/.gbrain/config.json
with remote_mcp set and NO local DB created.

Re-run guard (A8): when ~/.gbrain/config.json already has remote_mcp,
'gbrain init' (any flag set) refuses without --force. Catches the
scripted-setup-loop friction from the user-reported scenario where
re-running setup-gbrain on a thin-client machine kept trying to
re-create a local DB.

Two URLs in config (issuer + mcp) instead of one because OAuth
discovery + /token live at the issuer root while tool dispatch is at
/mcp — they compose from a common base in practice but reverse-proxy
setups need them explicit (codex review #2).

Tests: 15 cases covering happy path, env-var-supplied secret stays
out of disk, all four required-flag missing-error paths, three
smoke-failure paths, network-unreachable path, and the four re-run
guard variants (default/--pglite/--mcp-only without --force / with
--force). Uses async Bun.spawn (NOT execFileSync) — sync exec
deadlocks against in-process HTTP fixtures because the parent's
event loop can't accept connections while sync-blocked on a child.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): runRemoteDoctor for thin-client mode

Replaces every DB-bound check from runDoctor() with a tighter set
scoped to 'is the remote MCP we configured actually reachable?'.
Five checks:
  - config_integrity (URL fields well-formed)
  - oauth_credentials (secret resolvable from env or config file)
  - oauth_discovery (GET /.well-known/oauth-authorization-server)
  - oauth_token (POST /token client_credentials)
  - mcp_smoke (POST /mcp initialize)

Output shape matches the local doctor's Check surface so JSON
consumers can union the two without conditional logic. schema_version
is 2 (matches local doctor).

collectRemoteDoctorReport() is the pure data collector;
runRemoteDoctor() is the print/exit wrapper. Tests pin the data
collector so we don't have to intercept stdout / process.exit.

Tests: 12 cases over a tiny in-process HTTP fixture covering happy
path, every probe failure mode (404/parse/auth/network/server-error),
malformed-URL config integrity, missing-secret short-circuit, and
the env-var-overrides-config-file secret resolution. withEnv() helper
used for env mutations to satisfy the test-isolation lint.

Module is added but not yet wired into the CLI doctor branch; the
wiring lands in the next commit (cli dispatch guard + doctor routing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): thin-client dispatch guard + doctor routing

Adds a single canonical refusal at the top of handleCliOnly() for the
9 DB-bound commands when ~/.gbrain/config.json has remote_mcp set:
  sync, embed, extract, migrate, apply-migrations, repair-jsonb,
  orphans, integrity, serve

Single dispatch check (not 9 sprinkled assertLocalEngine calls per
codex review #1) — avoids the blast radius of letting commands enter
connectEngine before the check fires. Refused commands exit 1 with a
canonical error naming the remote mcp_url.

doctor branch routes to runRemoteDoctor when isThinClient(config)
returns true; falls through to the existing local-doctor flow
otherwise. Wires the module added in the previous commit into the
user-facing CLI surface.

Safe commands (init, auth, --version, --help, etc.) still work in
thin-client mode and are NOT in the refused set.

Tests: 14 cases — 9 refused commands × 1 each, 2 safe commands, 1
doctor-routing assertion (fingerprints the thin-client output by
'mode:"thin-client"' in JSON), 2 regression tests asserting local
config still passes through normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(topologies): multi-topology architecture guide + setup skill Phase A.5

New docs/architecture/topologies.md covering three deployment shapes:
  1. Single brain (today's default)
  2. Cross-machine thin client (consume a remote brain over MCP)
  3. Split-engine per-worktree (Conductor users with per-worktree
     code engines + shared remote artifacts brain)

Each topology gets an ASCII diagram, when-it-fits guidance, and
concrete setup recipes. Topology 3's alias-level routing footgun
(wrong alias = silent wrong-brain writes) is called out explicitly
per codex review #6.

Topology 3 needs zero gbrain code changes — GBRAIN_HOME already
overrides ~/.gbrain and 'gbrain serve --http --port N' already runs
on any port. gstack composes these primitives on its side.

skills/setup/SKILL.md gets Phase A.5 BEFORE the local-engine phases.
Asks the user which topology fits, walks thin-client setup through
'gbrain init --mcp-only', skips Phases B/C/C.5/H entirely for thin
clients (host's autopilot handles sync/extract/embed).

README.md gets a one-line link to the topology doc from the
Architecture section.

llms-full.txt regenerated to include the new doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): thin-client end-to-end skeleton

Spins up 'gbrain serve --http' against real Postgres, registers a
client with read,write,admin scope, runs 'gbrain init --mcp-only'
from a separate tempdir GBRAIN_HOME, exercises the canonical
thin-client flows:

  - init --mcp-only succeeds against the live host
  - doctor reports mode: thin-client + all checks green
  - sync is refused with the canonical thin-client error
  - re-running init refuses without --force

Tier B flows (gbrain remote ping / doctor) will be added alongside
their Lane B implementation. Skips when DATABASE_URL unset (matches
the e2e gate convention used across the suite).

Async Bun.spawn (NOT execFileSync) so the test event loop stays
responsive — execFileSync deadlocks against in-process HTTP fixtures
because the parent's event loop can't accept connections while
sync-blocked on a child process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): doctorReportRemote core for thin-client + run_doctor op

Adds three new exports to src/commands/doctor.ts that the run_doctor MCP
op + gbrain remote doctor CLI both consume:

  - DoctorReport interface       schema_version=2 stable shape
  - computeDoctorReport(checks)  status + health_score math
  - doctorReportRemote(engine)   focused 5-check thin-client surface

doctorReportRemote runs:
  1. connection      (engine reachable + page count via getStats)
  2. schema_version  (engine.getConfig('version') vs LATEST_VERSION)
  3. brain_score     (the 5-component composite)
  4. sync_failures   (file-plane JSONL count from gbrainPath('sync-failures.jsonl'))
  5. queue_health    (Postgres-only: stalled active jobs > 1h)

Engine-agnostic: works on both Postgres and PGLite via engine.executeRaw +
engine.getConfig + engine.getHealth — no reliance on db.getConnection()
which is Postgres-only.

Deliberately a focused subset of the local doctor surface, NOT a full
mirror. Generalizing to lint/integrity/orphans is filed as follow-up
pending demand. Local doctor (runDoctor) is unchanged; operators on the
host machine still get the full check set.

schema_version=2 matches the local doctor's --json output schema, so JSON
consumers can union the two without conditional logic.

Tests: 11 unit cases against PGLite covering the 5-check happy path,
schema version reporting (latest), PGLite-specific queue_health
informational message, and the score+status math via computeDoctorReport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(mcp-client): outbound HTTP MCP client over @modelcontextprotocol/sdk

New src/core/mcp-client.ts wraps the official SDK's Client +
StreamableHTTPClientTransport with OAuth client_credentials minting,
in-process token caching with expires_at, and refresh-on-401 retry.

Public surface:
  - callRemoteTool(config, toolName, args)   tool call w/ auto-refresh
  - unpackToolResult(res)                    parse content[0].text JSON
  - RemoteMcpError                           discriminated by `reason`

Token cache: module-level Map keyed by mcp_url. CLI processes are
short-lived; the cache amortizes when one invocation makes multiple
calls (gbrain remote ping submits then polls). Persisting to disk would
be a credential-on-disk surface for marginal benefit since /token
round-trip is sub-100ms.

401 retry: ONLY for mid-session token rotation (initial good token →
stale → 401). If the FIRST mint fails auth, surface immediately as
RemoteMcpError(auth) — retry won't help when credentials are wrong from
the start. If a fresh-mint-after-401 still 401s, surface as
RemoteMcpError(auth_after_refresh) which the CLI renders with a hint
pointing the operator at gbrain auth register-client.

Used by gbrain remote ping (submit_job + get_job poll) and gbrain
remote doctor (run_doctor). Test-only _clearMcpClientTokenCache export
for fixture isolation.

Tests: 13 unit cases over an in-process HTTP fixture mimicking gbrain
serve --http (OAuth discovery + /token + /mcp JSON-RPC handshake).
Covers happy path, token cache reuse + force-refresh, args passthrough,
config-error paths (no remote_mcp / no secret), token mint 401, network
unreachable, tool isError envelope, and unpackToolResult parse failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(operations): add run_doctor MCP op (admin scope, HTTP-reachable)

New op in src/core/operations.ts wraps doctorReportRemote() and returns
the structured DoctorReport JSON over MCP.

  scope:     'admin'       (system-state read; not for routine consumers)
  localOnly: false         (reachable over HTTP)
  mutating:  false         (safe to call repeatedly)
  params:    {}            (no caller arguments needed)

First read-only diagnostic op exposed over HTTP MCP. Used by gbrain
remote doctor — the matching client-side renderer lives in
src/commands/remote.ts.

Precedent: doctor only. Generalizing run_lint / run_integrity /
run_orphans to MCP is filed as follow-up work pending demand. Local
doctor stays unchanged; this op is the operator-friendly subset for
remote callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(remote): gbrain remote ping + gbrain remote doctor

Two thin-client convenience commands that round-trip through the host's
HTTP MCP endpoint:

  - gbrain remote ping     submit_job(autopilot-cycle) → poll get_job →
                           exit when terminal. The "I just wrote markdown,
                           tell the host to re-index" affordance.
  - gbrain remote doctor   run_doctor MCP op → render the host's
                           DoctorReport → exit 0/1 based on status.

Both require a thin-client install (~/.gbrain/config.json with
remote_mcp). Local installs get a clear error pointing at the local
equivalents.

Polling backoff (ping): 1s × 30s, then 5s × 5min, then 10s. Default cap
15min, configurable via `--timeout`. Without backoff, a 5-min cycle
would burn 300 round-trips against the host's rate limiter.

Payload uses `data: {phases: [...]}`, NOT `params:` — the submit_job op
shape takes `data`. Codex review #8 catch.

NO `repo` arg passed to autopilot-cycle — uses the server's configured
brain repo. This sidesteps TODO #1144 (sync_brain repo-path validation
for caller-controlled paths) entirely.

src/cli.ts wires the `remote` subcommand into CLI_ONLY + the dispatch.
Help (`gbrain remote --help`) and unknown-subcommand handling included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): thin-client Tier B + scope-mismatch regression

Extends the existing test/e2e/thin-client.test.ts with three new cases:

  1. gbrain remote doctor returns the host's DoctorReport — pins the
     run_doctor MCP op round-trip. Asserts schema_version=2, all 5
     check names present, connection + schema_version ok against a
     fresh host.
  2. gbrain remote ping triggers autopilot-cycle and returns terminal
     state — pins the submit_job → poll → terminal wire path. Accepts
     any terminal state (success / failed / dead / cancelled / timeout)
     because autopilot on an empty no-repo brain may fail-fast in the
     sync phase. What this test pins is the JSON shape (job_id present,
     state populated), NOT cycle success on a no-repo fixture.
  3. read+write client cannot call run_doctor — codex review #7
     regression guard. Registers a separate client with
     `--scopes "read write"` (no admin), runs `gbrain remote doctor`
     against it, asserts exit 1 with auth/auth_after_refresh/tool_error
     reason. Keeps the verification flow honest: the canonical setup
     MUST require admin scope.

`gbrain auth register-client` doesn't have --json, so the test parses
the human output for "Client ID:" and "Client Secret:" lines via a
helper.

Test-level timeout bumped 60s → 120s for the ping wait + auth/init
overhead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.29.2)

v0.29.2 ships thin-client mode: gbrain init --mcp-only, gbrain remote
ping/doctor, run_doctor MCP op, and the docs/architecture/topologies.md
deployment guide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:56:22 -07:00

276 lines
11 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);
expect(r.stderr).toContain('thin client');
expect(r.stderr).toContain(`http://127.0.0.1:${serverPort}/mcp`);
});
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');
});
// ─── 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 });
}
});
});