v0.31.1 feat: thin-client mode actually works (Issue #734) (#772)

* 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>
This commit is contained in:
Garry Tan
2026-05-09 19:47:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 89ae720959
commit b2fd26482e
22 changed files with 1891 additions and 155 deletions
+134
View File
@@ -2,6 +2,140 @@
All notable changes to GBrain will be documented in this file.
## [0.31.1] - 2026-05-08
**Thin-client mode actually works now. `gbrain init --mcp-only` is no longer a half-built bridge.**
**Every read + write + admin op routes through MCP; refused commands carry pinpoint hints.**
Hermes/Neuromancer hit this in production: thin-client install of wintermute (102k pages,
265k chunks). Every CLI search returned zero rows. Exit code 0. No warning. The agent
configured `gbrain init --mcp-only`, then walked into a wall of "no results found" against
a brain that had everything it needed. The CLI was opening the empty local PGLite,
running 38 migrations, and reporting "No results." while the real brain sat untouched
behind an HTTPS endpoint.
v0.31.1 fixes the silent-empty-results bug class for every operation surface gbrain has.
The CLI dispatch layer now routes every non-localOnly op through `callRemoteTool` when
`isThinClient(cfg)` is true, before opening any local engine. Read ops, write ops, admin
ops — all routed. Local-only commands (sync, embed, dream, integrity, etc) refuse with
pinpoint hints naming the closest path: "sync runs on the host. To trigger a remote
cycle, run `gbrain remote ping` (queues an autopilot-cycle job)."
### The numbers that matter
Reproducible against any host with a populated brain. Spin `gbrain serve --http`,
register an OAuth client with `read,write,admin`, then from a second `GBRAIN_HOME`:
| Behavior | v0.30.0 (broken) | v0.31.1 (fixed) |
|---|---|---|
| `gbrain search "X"` against 102k-page host | "No results." (exit 0) | Real rows + identity banner |
| `gbrain stats` on thin-client | Pages: 0, Chunks: 0 | Real numbers from host |
| `gbrain salience` | "(no pages touched in the salience window)" | Real salience output |
| `gbrain put 'wiki/test/foo' --content '...'` | Hits empty local DB | Persists on host |
| `gbrain sync` | Falls into PGLite migrations | Refuses with `gbrain remote ping` hint |
| `gbrain remote doctor` checks | 4 (config, oauth, discovery, smoke) | 5 (+ scope-probe with pinpoint remediation) |
| Identity feedback per command | None | `[thin-client → host:3131 · brain: 102k pages, 265k chunks · v0.31.1]` |
Per-call latency adds ~50-200ms warm (token cache hit), ~250-500ms cold (token mint).
Documented; users who want sub-50ms loops should run on the host.
### What this means for thin-client users
A thin-client `gbrain` install is now functionally substitutable for a local install
on the read+write+admin op surface. Agents get the topology signal up-front via the
identity banner; humans get pinpoint hints when something is genuinely local-only.
The OAuth-scope-mismatch class of failures (v0.29.2/v0.30.0 thin-clients registered
with `read+write` only and now hitting `gbrain stats`) surfaces during
`gbrain remote doctor` instead of mid-command, with an exact remediation:
`On the host: gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin`
## To take advantage of v0.31.1
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain remote doctor`
warns about the new `oauth_client_scopes_probe` check:
1. **On the THIN CLIENT** — re-run doctor to surface scope mismatches:
```bash
gbrain remote doctor
```
The new `oauth_client_scopes_probe` check reports per-tier status. If admin is
missing, the check tells you the exact host-side command to run.
2. **On the HOST (if doctor warned about admin scope)** — re-register your thin-client's
OAuth client with broader scopes:
```bash
gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin
```
Then update the thin-client's `~/.gbrain/config.json` `remote_mcp.oauth_client_id`
and (env-var or config) `oauth_client_secret` with the new credentials.
3. **Verify routing works** — run a representative read op that previously returned zero:
```bash
gbrain search "anything you know is in the brain"
gbrain stats
```
Both should now show real numbers and an identity banner like
`[thin-client → wintermute:3131 · brain: 102k pages, 265k chunks · v0.31.1]`.
4. **If any step fails or the numbers look wrong**, file an issue at
https://github.com/garrytan/gbrain/issues with `gbrain remote doctor` output.
### Itemized changes
#### Routing
- New `THIN_CLIENT_REFUSE_HINTS` table in `src/cli.ts` with pinpoint hints for every
refused command (sync/embed/extract/migrate/repair-jsonb/integrity/serve/dream/
orphans/transcripts/storage/takes/sources). Hints name the closest remote path
(e.g. `gbrain remote ping`) or specific MCP tools (e.g. `find_orphans`).
- New `runThinClientRouted` in cli.ts hooks INTO the existing op-dispatch path
(CDX-1: no parallel module). Every non-localOnly op routes through `callRemoteTool`
on thin-client installs.
- CLI-only commands with MCP equivalents (`think`, `salience`, `anomalies`,
`graph-query`) get per-command thin-client routing branches.
- ENG-2 renderer parity: local-engine path runs `JSON.parse(JSON.stringify(result))`
before formatting so renderers see the same shape on both paths.
#### New ops
- `get_brain_identity` (read scope, banner-only): cheap counter packet
`{version, engine, page_count, chunk_count, last_sync_iso}` for the thin-client
identity banner.
#### Banner
- Identity banner prints to stderr before each routed command. 60s TTL in-memory
cache keyed by mcp_url. Suppression: `--quiet`, `GBRAIN_NO_BANNER=1`, non-TTY
default (override with `GBRAIN_BANNER=1`).
#### Doctor
- New `oauth_client_scopes_probe` check (CDX-5) in `gbrain remote doctor`. Surfaces
v0.29.2/v0.30.0 thin-clients without admin scope BEFORE they hit
`gbrain stats`/`gbrain history` mid-command.
#### Error handling
- Hardened `callRemoteTool` (CDX-4): all transport errors normalized to
`RemoteMcpError` with stable `reason` union and `kind`/`code` sub-tags on detail.
Dispatcher's exhaustive TS `never` switch produces canned, actionable messages.
- New `--timeout=Ns` global flag (ENG-4); SIGINT aborts via AbortController.
#### Tests
- New `test/get-brain-identity.test.ts` (9), `test/oauth-scope-probe.test.ts` (8).
- Updated `test/cli-options.test.ts` (+8 --timeout cases, 28 total).
- Updated `test/cli-dispatch-thin-client.test.ts` (14 cases, refreshed for new
pinpoint-hint format).
### For contributors
- Plan + decision history at `~/.claude/plans/how-to-make-mcp-iterative-liskov.md`.
Records 6 CEO-review decisions (D1-D6), 5 eng-review decisions (ENG-1..5), and
6 codex outside-voice decisions (CDX-1..6). Codex's #1+#8 forced an architecture
rewrite (no parallel `src/core/thin-client/` module; route inline in cli.ts).
Codex's #2+#7 forced honest framing (no false "Liskov substitutability" claim
— server intentionally treats remote callers differently for `think.--save`/
`--take` and `put_page` auto-link, both trust-boundary policy decisions).
Codex's #5 caught a false premise in the error-model design.
- Per-subcommand thin-client routing for `takes` and `sources` is a v0.31.x
follow-up TODO. v0.31.1 refuses both at the top level with hints pointing at
MCP tools.
## [0.31.0] - 2026-05-08
**Hot memory ships. Your brain remembers what you said today, across sessions.**
+57
View File
@@ -248,6 +248,63 @@ gbrain-evals consumes: `gbrain/engine`, `gbrain/types`, `gbrain/operations`,
`gbrain/extract`. Removing any of these is a breaking change for the
gbrain-evals consumer.
## Thin-client routing (v0.31.1, Issue #734)
`gbrain init --mcp-only` (v0.29.2) sets up a thin-client install: no local
brain content, just an OAuth client pointing at a remote `gbrain serve --http`.
v0.29.2/v0.30.0 only refused 9 obvious local-only commands; the other ~25
silently fell through to `connectEngine()` and opened the empty local PGLite,
returning "No results." against a populated remote brain. v0.31.1 fixes the
silent-empty-results bug class for every operation surface.
Key files:
- `src/cli.ts` — Routing seam INSIDE the existing op-dispatch path (CDX-1: no
parallel `src/core/thin-client/` module; routing is a ~80-line conditional
in `runThinClientRouted`). Detects `isThinClient(cfg)` BEFORE `connectEngine`
so thin-client installs never open the empty PGLite. localOnly ops on
thin-client refuse via `refuseThinClient` (with pinpoint hint table
`THIN_CLIENT_REFUSE_HINTS`). Banner via `printIdentityBannerBestEffort`
before each routed call (suppressed by `--quiet`, `GBRAIN_NO_BANNER=1`,
non-TTY default). Exhaustive TS `never` switch on `RemoteMcpError.reason`
for canned, actionable error messages. ENG-2 renderer parity: local-engine
path runs `JSON.parse(JSON.stringify(result))` so renderers see the same
shape on both paths (kills Date/bigint/Buffer drift class).
- `src/core/mcp-client.ts``callRemoteTool(config, toolName, args, opts)`.
Hardened in v0.31.1 (CDX-4): all transport errors normalized to
`RemoteMcpError` via the `toRemoteMcpError` funnel. New `CallRemoteToolOptions
{timeoutMs, signal}`; `buildAbortController` composes external signal with
timeout. New `RemoteMcpErrorReason` stable union, `RemoteMcpErrorDetail.kind`
('timeout' | 'aborted' | 'unreachable') sub-tag, `RemoteMcpErrorDetail.code`
field carrying server-supplied error codes (e.g. `missing_scope`).
`extractToolErrorCode` parses JSON envelopes first, falls back to substring
detection for legacy server messages. `unpackToolResult<T>(res)` unchanged
(parses tool-call JSON content). `_clearMcpClientTokenCache()` test escape.
- `src/core/cli-options.ts``parseGlobalFlags` adds `--timeout=Ns` (accepts
`30s`, `2m`, `500ms`, plain ms). Default `null` = per-command default (30s
for most ops, 180s for `think`). `parseTimeout(s)` exported helper.
- `src/core/doctor-remote.ts``gbrain remote doctor` adds the
`oauth_client_scopes_probe` check (CDX-5). Probes the read tier via
`get_brain_identity` and admin tier via `get_health`; reports per-tier
status with pinpoint remediation when admin is missing. `buildScopeCheck`
+ `ScopeProbeResult` exported for test access. Skippable via
`GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1` for fixtures that mock /mcp at JSON-RPC
initialize level only (MCP SDK Client hangs on shape mismatch).
- `src/core/operations.ts``get_brain_identity` op (read scope, no params,
banner-only): cheap counter packet `{version, engine, page_count,
chunk_count, last_sync_iso}` for the thin-client identity banner. Reuses
`engine.getStats()`; banner's 60s client-side TTL bounds frequency to
≤1/60s per CLI process (well below the Fly.io health-check cadence that
motivated the original `getStats` cost warning).
- `src/commands/{salience,anomalies,graph-query,think}.ts` — Per-command
thin-client routing branches. These commands bypass the operation-layer
dispatch in cli.ts (call `engine.foo()` directly), so each gets its own
`if (isThinClient(cfg)) { callRemoteTool(...) }` branch that maps CLI flags
to op params. `think` is a special case: the server's `think` op
intentionally disables `--save`/`--take` for remote callers
(operations.ts:1103-1135 trust-boundary gate); thin-client `think` warns
loudly when those flags are set.
## Commands
Run `gbrain --help` or `gbrain --tools-json` for full command reference.
+48
View File
@@ -1,5 +1,53 @@
# TODOS
## Thin-client mode follow-ups (v0.31.1, Issue #734)
- [ ] **v0.31.x: routed-call timing telemetry.** `GBRAIN_TIMING=1` prints
`token_mint=Xms http=Yms server=Zms total=Wms` per routed MCP call.
Audit log at `~/.gbrain/audit/routed-calls-YYYY-Www.jsonl`. Cherry-pick
C from #734 plan; deferred from v0.31.1 to keep scope tight.
- [ ] **v0.31.2: job-submission routing for `gbrain dream` etc.** Route
long-running ops (`dream`, `embed --stale`, `extract`) via `submit_job`
+ poll, mirroring the existing `gbrain remote ping` autopilot-cycle
pattern. Cherry-pick D from #734 plan. Adds a thin-client async-job
render layer (progress events + spinner).
- [ ] **Per-subcommand thin-client routing for `takes` and `sources`.**
CDX-2 audit identified the READ subcommands (`takes_list`, `takes_search`,
`sources_list`, `sources_status`) as routable; mutate subcommands edit
local files. v0.31.1 refuses both at the top level with hints. Split
is a v0.31.x release.
- [ ] **Privacy decision: lift `localOnly: true` on `get_recent_transcripts`?**
Raw chat exports leaving the host is a real tradeoff. Needs explicit
per-token scope (`scope: 'transcripts'`) and consent UX. Out of v0.31.1.
- [ ] **Trust-boundary policy review for remote-caller gates.** Server
intentionally disables `think.--save`/`--take` for remote callers
(operations.ts:1103-1135) and skips `put_page` auto-link/auto-timeline
for remote callers without `trustedWorkspace` (operations.ts:434-451).
Subagent-isolation reasons; blocks full thin-client parity. Policy
decision, not a routing fix.
- [ ] **v0.32.0: flip `gbrain auth register-client` default scope from
`read` to `read,write,admin`.** Breaking for existing read-only scrapers;
ship deprecation warning in v0.31.x. The v0.31.1 `oauth_client_scopes_probe`
doctor check surfaces the gap with pinpoint remediation in the meantime.
- [ ] **v0.31.x: cross-process OAuth token cache at
`~/.gbrain/oauth-token-cache.json`.** Cuts ~200ms cold-start cost for
shell-loop usage on thin-client installs. Today the in-memory cache is
per-process; every `gbrain` invocation pays a fresh token mint.
- [ ] **v0.31.x: parity test (`test/thin-client-parity.test.ts`).** Plan
called for ~400 LOC byte-equal stdout assertions for 12+ ops via an
in-process MCP server pointed at the same PGLite as the local-engine
path. Harder than expected because it needs MCP server setup that the
current test infrastructure doesn't expose. v0.31.1 ships without it;
ENG-2's JSON-shape normalization + per-command test coverage is the
interim guard.
## LongMemEval benchmark follow-ups (v0.28.12)
### Closed: full 500-question 4-adapter run published
+1 -1
View File
@@ -1 +1 @@
0.31.0
0.31.1
+57
View File
@@ -348,6 +348,63 @@ gbrain-evals consumes: `gbrain/engine`, `gbrain/types`, `gbrain/operations`,
`gbrain/extract`. Removing any of these is a breaking change for the
gbrain-evals consumer.
## Thin-client routing (v0.31.1, Issue #734)
`gbrain init --mcp-only` (v0.29.2) sets up a thin-client install: no local
brain content, just an OAuth client pointing at a remote `gbrain serve --http`.
v0.29.2/v0.30.0 only refused 9 obvious local-only commands; the other ~25
silently fell through to `connectEngine()` and opened the empty local PGLite,
returning "No results." against a populated remote brain. v0.31.1 fixes the
silent-empty-results bug class for every operation surface.
Key files:
- `src/cli.ts` — Routing seam INSIDE the existing op-dispatch path (CDX-1: no
parallel `src/core/thin-client/` module; routing is a ~80-line conditional
in `runThinClientRouted`). Detects `isThinClient(cfg)` BEFORE `connectEngine`
so thin-client installs never open the empty PGLite. localOnly ops on
thin-client refuse via `refuseThinClient` (with pinpoint hint table
`THIN_CLIENT_REFUSE_HINTS`). Banner via `printIdentityBannerBestEffort`
before each routed call (suppressed by `--quiet`, `GBRAIN_NO_BANNER=1`,
non-TTY default). Exhaustive TS `never` switch on `RemoteMcpError.reason`
for canned, actionable error messages. ENG-2 renderer parity: local-engine
path runs `JSON.parse(JSON.stringify(result))` so renderers see the same
shape on both paths (kills Date/bigint/Buffer drift class).
- `src/core/mcp-client.ts` — `callRemoteTool(config, toolName, args, opts)`.
Hardened in v0.31.1 (CDX-4): all transport errors normalized to
`RemoteMcpError` via the `toRemoteMcpError` funnel. New `CallRemoteToolOptions
{timeoutMs, signal}`; `buildAbortController` composes external signal with
timeout. New `RemoteMcpErrorReason` stable union, `RemoteMcpErrorDetail.kind`
('timeout' | 'aborted' | 'unreachable') sub-tag, `RemoteMcpErrorDetail.code`
field carrying server-supplied error codes (e.g. `missing_scope`).
`extractToolErrorCode` parses JSON envelopes first, falls back to substring
detection for legacy server messages. `unpackToolResult<T>(res)` unchanged
(parses tool-call JSON content). `_clearMcpClientTokenCache()` test escape.
- `src/core/cli-options.ts` — `parseGlobalFlags` adds `--timeout=Ns` (accepts
`30s`, `2m`, `500ms`, plain ms). Default `null` = per-command default (30s
for most ops, 180s for `think`). `parseTimeout(s)` exported helper.
- `src/core/doctor-remote.ts` — `gbrain remote doctor` adds the
`oauth_client_scopes_probe` check (CDX-5). Probes the read tier via
`get_brain_identity` and admin tier via `get_health`; reports per-tier
status with pinpoint remediation when admin is missing. `buildScopeCheck`
+ `ScopeProbeResult` exported for test access. Skippable via
`GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1` for fixtures that mock /mcp at JSON-RPC
initialize level only (MCP SDK Client hangs on shape mismatch).
- `src/core/operations.ts` — `get_brain_identity` op (read scope, no params,
banner-only): cheap counter packet `{version, engine, page_count,
chunk_count, last_sync_iso}` for the thin-client identity banner. Reuses
`engine.getStats()`; banner's 60s client-side TTL bounds frequency to
≤1/60s per CLI process (well below the Fly.io health-check cadence that
motivated the original `getStats` cost warning).
- `src/commands/{salience,anomalies,graph-query,think}.ts` — Per-command
thin-client routing branches. These commands bypass the operation-layer
dispatch in cli.ts (call `engine.foo()` directly), so each gets its own
`if (isThinClient(cfg)) { callRemoteTool(...) }` branch that maps CLI flags
to op params. `think` is a special case: the server's `think` op
intentionally disables `--save`/`--take` for remote callers
(operations.ts:1103-1135 trust-boundary gate); thin-client `think` warns
loudly when those flags are set.
## Commands
Run `gbrain --help` or `gbrain --tools-json` for full command reference.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.31.0",
"version": "0.31.1",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+344 -47
View File
@@ -12,6 +12,8 @@ import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import type { CliOptions } from './core/cli-options.ts';
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
@@ -82,47 +84,72 @@ async function main() {
process.exit(1);
}
// v0.31.1 (Issue #734, CDX-1): parse CLI args BEFORE engine connect so
// the routing seam below can decide local-vs-remote without paying a
// PGLite migration replay on thin-client installs. The arg parser, image
// transform, and required-param check are all engine-free; refactoring
// them out of the engine try/catch is safe and unlocks routing.
const params = parseOpArgs(op, subArgs);
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
// a filesystem path into base64 bytes + mime. The op accepts base64; the
// CLI accepts a path. Helper is exported so tests can exercise the
// transform without spawning a subprocess.
if (op.name === 'query' && typeof params.image === 'string' && params.image.length > 0) {
try {
const { path, base64, mime } = resolveQueryImage(
params.image as string,
(params.image_mime as string) || undefined,
);
params.image = base64;
params.image_mime = mime;
void path;
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
// Validate required params before calling handler. v0.27.1: the
// `query` op's positional `query` is required only when --image is
// NOT supplied. The runtime altRequired check below overrides the
// generic required-flag check for that op.
const queryHasAlt = op.name === 'query' && typeof params.image === 'string' && params.image.length > 0;
for (const [key, def] of Object.entries(op.params)) {
if (def.required && params[key] === undefined) {
if (queryHasAlt && key === 'query') continue;
const cliName = op.cliHints?.name || op.name;
const positional = op.cliHints?.positional || [];
const usage = positional.map(p => `<${p}>`).join(' ');
console.error(`Usage: gbrain ${cliName} ${usage}`);
process.exit(1);
}
}
// v0.31.1 (Issue #734, CDX-1 routing seam): on thin-client installs,
// route every non-localOnly op through callRemoteTool instead of opening
// the empty local PGLite. localOnly ops can't run on a thin client at all
// (no local engine, server intentionally hides them) — refuse with hint.
// Fix for the silent-empty-results bug class that motivated this whole release.
const cfgPre = loadConfig();
if (isThinClient(cfgPre)) {
if (op.localOnly) {
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
}
await runThinClientRouted(op, params, cfgPre!, cliOpts);
return;
}
// Local engine path (unchanged behavior for local installs).
const engine = await connectEngine();
try {
const params = parseOpArgs(op, subArgs);
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
// a filesystem path into base64 bytes + mime. The op accepts base64; the
// CLI accepts a path. Helper is exported so tests can exercise the
// transform without spawning a subprocess.
if (op.name === 'query' && typeof params.image === 'string' && params.image.length > 0) {
try {
const { path, base64, mime } = resolveQueryImage(
params.image as string,
(params.image_mime as string) || undefined,
);
params.image = base64;
params.image_mime = mime;
void path;
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
// Validate required params before calling handler. v0.27.1: the
// `query` op's positional `query` is required only when --image is
// NOT supplied. The runtime altRequired check below overrides the
// generic required-flag check for that op.
const queryHasAlt = op.name === 'query' && typeof params.image === 'string' && params.image.length > 0;
for (const [key, def] of Object.entries(op.params)) {
if (def.required && params[key] === undefined) {
if (queryHasAlt && key === 'query') continue;
const cliName = op.cliHints?.name || op.name;
const positional = op.cliHints?.positional || [];
const usage = positional.map(p => `<${p}>`).join(' ');
console.error(`Usage: gbrain ${cliName} ${usage}`);
process.exit(1);
}
}
const ctx = makeContext(engine, params);
const result = await op.handler(ctx, params);
const rawResult = await op.handler(ctx, params);
// ENG-2 (renderer parity by data shape): JSON-round-trip the local-engine
// path's return value so renderers see the same shape they'd see on the
// routed path. Date → ISO string; bigint → string (postgres.js shape);
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
const result = JSON.parse(JSON.stringify(rawResult));
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
@@ -138,6 +165,224 @@ async function main() {
}
}
/**
* v0.31.1 (Issue #734, CDX-1): route a shared op through the remote MCP
* server instead of running it locally. Called from main() when
* `isThinClient(cfg) && !op.localOnly`.
*
* Timeout policy (ENG-4): user override via --timeout=Ns wins; otherwise
* 180s for `think` (LLM calls), 30s for everything else.
*
* Error policy (CDX-4): callRemoteTool's hardening pass guarantees every
* thrown value reaches us as a RemoteMcpError. The switch below is
* exhaustively typed (TS `never` check); adding a new reason variant fails
* compilation until this dispatcher knows what to render.
*
* Renderer policy: the MCP tool result is unpacked via unpackToolResult
* (which JSON.parses the text content) and handed to the SAME formatResult
* the local-engine path uses. Renderer parity is enforced by data shape,
* not by per-command audit.
*/
async function runThinClientRouted(
op: Operation,
params: Record<string, unknown>,
cfg: GBrainConfig,
cliOpts: CliOptions,
): Promise<void> {
// ENG-4: per-op timeout default; user override wins.
const defaultTimeoutMs = op.name === 'think' ? 180_000 : 30_000;
const timeoutMs = cliOpts.timeoutMs ?? defaultTimeoutMs;
// SIGINT support: aborts in-flight HTTP cleanly (exit 130 is the standard
// SIGINT exit code; our error switch maps `network/aborted` to that).
const sigintController = new AbortController();
const onSigint = () => {
sigintController.abort(new Error('SIGINT'));
};
process.on('SIGINT', onSigint);
// v0.31.1 (Issue #734, cherry-pick B): print identity banner to stderr
// BEFORE the routed call. Banner failure suppresses the banner only —
// never the underlying command. Suppression honors --quiet, non-TTY,
// and GBRAIN_NO_BANNER=1.
await printIdentityBannerBestEffort(cfg, cliOpts, sigintController.signal);
try {
const raw = await callRemoteTool(cfg, op.name, params, {
timeoutMs,
signal: sigintController.signal,
});
const result = unpackToolResult(raw);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
const url = cfg.remote_mcp!.mcp_url;
switch (e.reason) {
case 'config':
console.error(e.message);
break;
case 'discovery':
console.error(`OAuth discovery failed at ${cfg.remote_mcp!.issuer_url}.`);
console.error('Run `gbrain remote doctor` for details.');
break;
case 'auth':
console.error('OAuth auth failed.');
console.error('On the host, re-register your client:');
console.error(' gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin');
break;
case 'auth_after_refresh':
console.error('OAuth auth failed after token refresh. Credentials may have been revoked.');
console.error('Run `gbrain remote doctor` to confirm.');
break;
case 'network':
if (e.detail?.kind === 'timeout') {
const hint = cliOpts.timeoutMs ? '' : ` (default ${defaultTimeoutMs}ms; pass --timeout=Ns to override)`;
console.error(`Request to ${url} timed out${hint}.`);
} else if (e.detail?.kind === 'aborted') {
console.error('Request aborted.');
process.off('SIGINT', onSigint);
process.exit(130);
} else {
console.error(`Cannot reach ${url}. Run \`gbrain remote doctor\` for details.`);
}
break;
case 'tool_error':
if (e.detail?.code === 'missing_scope') {
console.error('Missing OAuth scope on this client.');
console.error('On the host, re-register the client with broader scopes:');
console.error(' gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin');
} else {
console.error(e.message);
console.error('Run `gbrain remote doctor` if this persists.');
}
break;
case 'parse':
console.error('Server response was malformed. Run `gbrain remote doctor`.');
break;
default: {
// Exhaustive switch sentinel (TS `never` — fails to build if a
// new RemoteMcpErrorReason variant is added without a case).
const _exhaustive: never = e.reason;
void _exhaustive;
console.error(`Unhandled remote error: ${e.message}`);
}
}
process.off('SIGINT', onSigint);
process.exit(1);
}
// Defense in depth: callRemoteTool's contract is that everything is
// RemoteMcpError. If a plain Error escapes, render it generically and
// exit 1 — but this should never happen post-CDX-4.
console.error(e instanceof Error ? e.message : String(e));
process.off('SIGINT', onSigint);
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
}
}
// ============================================================================
// v0.31.1 (Issue #734, cherry-pick B): thin-client identity banner.
//
// Prints "[thin-client → <host> · brain: 102k pages, 265k chunks · vX.Y.Z]"
// to stderr before each routed command, so users (and agents) know they're
// talking to a real remote brain — not the empty local PGLite that motivated
// this whole release.
//
// Cache: 60s TTL, in-memory Map keyed by mcp_url. Cross-process file cache
// is deferred (marginal benefit; one mint per CLI process is fine).
// Suppression: --quiet, non-TTY, GBRAIN_NO_BANNER=1.
// Failure mode: any error in fetching identity → suppress banner; underlying
// command runs normally. Banner is observability, not load-bearing.
// ============================================================================
interface BrainIdentity {
version: string;
engine: 'postgres' | 'pglite';
page_count: number;
chunk_count: number;
last_sync_iso: string | null;
}
interface CachedIdentity {
identity: BrainIdentity;
cached_at_ms: number;
}
const IDENTITY_TTL_MS = 60_000;
const identityCache = new Map<string, CachedIdentity>();
/** Test-only escape hatch — clears the in-memory cache between test runs. */
export function _clearIdentityCacheForTest(): void {
identityCache.clear();
}
function bannerSuppressed(cliOpts: CliOptions): boolean {
if (cliOpts.quiet) return true;
if (process.env.GBRAIN_NO_BANNER === '1') return true;
// Non-TTY default is suppressed (clean pipes); explicit env-flag overrides.
if (!process.stderr.isTTY && process.env.GBRAIN_BANNER !== '1') return true;
return false;
}
function formatPageCount(n: number): string {
if (n >= 1000) {
const k = (n / 1000).toFixed(n >= 100_000 ? 0 : 1);
return `${k}k`;
}
return String(n);
}
function formatBanner(mcpUrl: string, id: BrainIdentity): string {
const host = mcpUrl.replace(/^https?:\/\//, '').split('/')[0];
const counts = `brain: ${formatPageCount(id.page_count)} pages, ${formatPageCount(id.chunk_count)} chunks`;
return `[thin-client → ${host} · ${counts} · v${id.version}]`;
}
async function fetchIdentity(
cfg: GBrainConfig,
signal: AbortSignal,
): Promise<BrainIdentity> {
// 2s timeout for the banner fetch — must not delay the underlying command.
const raw = await callRemoteTool(cfg, 'get_brain_identity', {}, {
timeoutMs: 2000,
signal,
});
const id = unpackToolResult<BrainIdentity>(raw);
return id;
}
async function printIdentityBannerBestEffort(
cfg: GBrainConfig,
cliOpts: CliOptions,
signal: AbortSignal,
): Promise<void> {
if (bannerSuppressed(cliOpts)) return;
const mcpUrl = cfg.remote_mcp?.mcp_url;
if (!mcpUrl) return;
// Cache lookup keyed by mcp_url so switching hosts via `gbrain init`
// invalidates cleanly even within a long-lived process.
const cached = identityCache.get(mcpUrl);
if (cached && Date.now() - cached.cached_at_ms < IDENTITY_TTL_MS) {
process.stderr.write(formatBanner(mcpUrl, cached.identity) + '\n');
return;
}
// Cache miss — fetch. Failure is non-fatal: banner is observability,
// never load-bearing for the underlying command.
try {
const id = await fetchIdentity(cfg, signal);
identityCache.set(mcpUrl, { identity: id, cached_at_ms: Date.now() });
process.stderr.write(formatBanner(mcpUrl, id) + '\n');
} catch {
// Swallow. Banner suppressed; main command continues. The CDX-4
// hardened callRemoteTool will surface the same error class on the
// actual command call if the host is genuinely unreachable.
}
}
/**
* v0.27.1: shared transform for `gbrain query --image <path>` (and any future
* CLI surface that takes an image path). Reads the file, base64-encodes,
@@ -337,21 +582,73 @@ function formatResult(opName: string, result: unknown): string {
const THIN_CLIENT_REFUSED_COMMANDS = new Set([
'sync', 'embed', 'extract', 'migrate', 'apply-migrations',
'repair-jsonb', 'orphans', 'integrity', 'serve',
// v0.31.1 (CDX-2 op coverage matrix): more local-only commands
'dream', 'transcripts', 'storage',
// v0.31.1 CDX-2 audit: takes/sources have multiple subcommands; some
// (takes_list/takes_search, sources_list/sources_status) have MCP
// equivalents and others are file-system bound (takes mutate commands
// edit local .md files). v0.31.1 refuses both at the top level with a
// hint pointing at the routable MCP tools; per-subcommand splits are
// a v0.31.x follow-up TODO.
'takes', 'sources',
]);
/**
* v0.31.1 (Issue #734, CDX-5 + cherry-pick A): pinpoint refusal hints for
* local-only commands when running on a thin-client install. Each hint names
* the closest path (remote MCP call, host-side workflow) so users aren't
* stuck guessing what to do next.
*
* Source-of-truth lives here so adding a new local-only command means
* adding both the THIN_CLIENT_REFUSED_COMMANDS member AND the hint in one
* place during code review.
*/
const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
sync: 'sync runs on the host. Trigger a remote cycle with `gbrain remote ping` (queues an autopilot-cycle job).',
embed: 'embed runs on the host as part of the autopilot cycle. `gbrain remote ping` triggers a full cycle including embed.',
extract: 'extract runs on the host. Use `gbrain remote ping` to trigger a cycle including extract.',
migrate: "migrate runs on the host's local engine. Run on the host machine.",
'apply-migrations': 'schema migrations run on the host. SSH and run there.',
'repair-jsonb': 'repair-jsonb operates on the local DB only.',
integrity: 'integrity scans local files. Run on the host machine.',
serve: 'serve starts a server. Run on the host, not the thin client.',
dream: 'dream runs the autopilot cycle on the host. `gbrain remote ping` queues one. (Native `gbrain dream` thin-client routing planned for v0.31.2.)',
orphans: "orphans needs the host's brain. Run on the host or use the `find_orphans` MCP tool from your agent.",
transcripts: 'transcripts is server-private (raw chat exports stay on the host). Read transcripts on the host machine.',
storage: 'storage operates on the local repo on disk. Run on the host.',
takes: 'takes mutate subcommands edit local .md files; routing the read subcommands lands in v0.31.x. For now: use `takes_list` and `takes_search` MCP tools from your agent, or run on the host.',
sources: 'sources commands manage local DB + config rows. Per-subcommand thin-client routing lands in v0.31.x. For now: use `sources_list` / `sources_status` MCP tools, or run on the host.',
};
/**
* v0.31.1: emit a pinpoint refusal hint for a thin-client-incompatible
* command and exit 1. Falls back to the canonical generic message when no
* specific hint is registered (defensive every member of
* THIN_CLIENT_REFUSED_COMMANDS should have a hint).
*/
function refuseThinClient(command: string, mcpUrl: string): never {
const hint = THIN_CLIENT_REFUSE_HINTS[command];
if (hint) {
console.error(`\`gbrain ${command}\` is not routable. ${hint}`);
console.error(`(thin-client of ${mcpUrl})`);
} else {
console.error(
`\`gbrain ${command}\` requires a local engine. This install is a thin client of ${mcpUrl}.\n` +
`Run \`${command}\` on the remote host, or use the corresponding MCP tool from your agent.`,
);
}
process.exit(1);
}
async function handleCliOnly(command: string, args: string[]) {
// Thin-client guard: refuse DB-bound commands cleanly with a single
// canonical message instead of letting them fail later inside connectEngine
// or mid-handler. See `THIN_CLIENT_REFUSED_COMMANDS` above.
// Thin-client guard: refuse DB-bound commands cleanly with a pinpoint
// hint instead of letting them fail later inside connectEngine or
// mid-handler. v0.31.1 routes through `refuseThinClient` so every
// refusal carries an actionable next-step hint (CDX-5 cherry-pick A).
if (THIN_CLIENT_REFUSED_COMMANDS.has(command)) {
const cfg = loadConfig();
if (isThinClient(cfg)) {
const url = cfg!.remote_mcp!.mcp_url;
console.error(
`\`gbrain ${command}\` requires a local engine. This install is a thin client of ${url}.\n` +
`Run \`${command}\` on the remote host, or use the corresponding MCP tool from your agent.`,
);
process.exit(1);
refuseThinClient(command, cfg!.remote_mcp!.mcp_url);
}
}
+19 -5
View File
@@ -16,6 +16,8 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
interface RunOpts {
since?: string;
@@ -67,11 +69,23 @@ export async function runAnomalies(engine: BrainEngine, args: string[]): Promise
console.log(HELP);
return;
}
const rows = await engine.findAnomalies({
since: parsed.since,
lookback_days: parsed.lookbackDays,
sigma: parsed.sigma,
});
// v0.31.1 (Issue #734): on thin-client installs, route via MCP.
let rows;
const cfg = loadConfig();
if (isThinClient(cfg)) {
const raw = await callRemoteTool(cfg!, 'find_anomalies', {
since: parsed.since,
lookback_days: parsed.lookbackDays,
sigma: parsed.sigma,
}, { timeoutMs: 30_000 });
rows = unpackToolResult<Awaited<ReturnType<BrainEngine['findAnomalies']>>>(raw);
} else {
rows = await engine.findAnomalies({
since: parsed.since,
lookback_days: parsed.lookbackDays,
sigma: parsed.sigma,
});
}
if (parsed.json) {
console.log(JSON.stringify(rows, null, 2));
return;
+22 -5
View File
@@ -16,6 +16,8 @@
import type { BrainEngine } from '../core/engine.ts';
import type { GraphPath } from '../core/types.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
interface Args {
slug?: string;
@@ -72,11 +74,26 @@ export async function runGraphQuery(engine: BrainEngine, argv: string[]) {
return;
}
const paths = await engine.traversePaths(args.slug, {
depth: args.depth,
linkType: args.linkType,
direction: args.direction,
});
// v0.31.1 (Issue #734): on thin-client installs, route via MCP. The
// traverse_graph op returns GraphPath[] when link_type or direction is
// set (which the CLI always does); unpackToolResult parses the JSON.
let paths: GraphPath[];
const cfg = loadConfig();
if (isThinClient(cfg)) {
const raw = await callRemoteTool(cfg!, 'traverse_graph', {
slug: args.slug,
depth: args.depth,
link_type: args.linkType,
direction: args.direction,
}, { timeoutMs: 30_000 });
paths = unpackToolResult<GraphPath[]>(raw);
} else {
paths = await engine.traversePaths(args.slug, {
depth: args.depth,
linkType: args.linkType,
direction: args.direction,
});
}
if (paths.length === 0) {
console.log(`No edges found from ${args.slug}${args.linkType ? ` (--type ${args.linkType})` : ''}.`);
+23 -5
View File
@@ -14,6 +14,8 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
interface RunOpts {
days?: number;
@@ -67,11 +69,27 @@ export async function runSalience(engine: BrainEngine, args: string[]): Promise<
console.log(HELP);
return;
}
const rows = await engine.getRecentSalience({
days: parsed.days,
limit: parsed.limit,
slugPrefix: parsed.slugPrefix,
});
// v0.31.1 (Issue #734): on thin-client installs, route the engine call
// through the remote `get_recent_salience` MCP op. Output format is
// identical because both paths return the same shape (the op handler IS
// engine.getRecentSalience).
let rows;
const cfg = loadConfig();
if (isThinClient(cfg)) {
const raw = await callRemoteTool(cfg!, 'get_recent_salience', {
days: parsed.days,
limit: parsed.limit,
slugPrefix: parsed.slugPrefix,
}, { timeoutMs: 30_000 });
rows = unpackToolResult<Awaited<ReturnType<BrainEngine['getRecentSalience']>>>(raw);
} else {
rows = await engine.getRecentSalience({
days: parsed.days,
limit: parsed.limit,
slugPrefix: parsed.slugPrefix,
});
}
if (parsed.json) {
console.log(JSON.stringify(rows, null, 2));
return;
+35 -11
View File
@@ -7,6 +7,8 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { runThink, persistSynthesis } from '../core/think/index.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
function flagValue(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
@@ -72,19 +74,41 @@ the gather phase still runs and prints what would have been the input.
process.exit(1);
}
const result = await runThink(engine, {
question, anchor, rounds, save, take, model, since, until,
// Local CLI: no MCP allow-list filter — operator owns the brain.
});
// Persist if --save (the runThink path doesn't auto-persist; CLI does it explicitly)
// v0.31.1 (Issue #734): on thin-client installs, route through MCP. The
// server's `think` handler intentionally ignores --save and --take for
// remote callers (operations.ts:1103-1135 trust-boundary gate). Document
// here loudly so users get a clear warning instead of silent loss.
let result: any;
let savedSlug: string | undefined;
let evidenceInserted = 0;
if (save) {
const persisted = await persistSynthesis(engine, result);
savedSlug = persisted.slug;
evidenceInserted = persisted.evidenceInserted;
for (const w of persisted.warnings) result.warnings.push(w);
const cfg = loadConfig();
if (isThinClient(cfg)) {
if (save || take) {
console.error(
'[thin-client] --save and --take are server-gated for remote callers ' +
'(trust-boundary policy). Run on the host or use the MCP `think` tool ' +
'with the `viaSubagent` context if you need persistence.',
);
}
const raw = await callRemoteTool(cfg!, 'think', {
question, anchor, rounds, model, since, until,
// save/take intentionally NOT forwarded — server would ignore them;
// we surface the intent above so users know what they lose.
}, { timeoutMs: 180_000 });
result = unpackToolResult<any>(raw);
} else {
result = await runThink(engine, {
question, anchor, rounds, save, take, model, since, until,
// Local CLI: no MCP allow-list filter — operator owns the brain.
});
// Persist if --save (the runThink path doesn't auto-persist; CLI does it explicitly)
if (save) {
const persisted = await persistSynthesis(engine, result);
savedSlug = persisted.slug;
evidenceInserted = persisted.evidenceInserted;
for (const w of persisted.warnings) result.warnings.push(w);
}
}
if (json) {
+48
View File
@@ -15,12 +15,20 @@ export interface CliOptions {
quiet: boolean;
progressJson: boolean;
progressInterval: number; // ms
/**
* v0.31.1 (Issue #734, ENG-4): user-supplied per-call timeout for thin-client
* routed MCP calls. `null` means "use the per-command default" (30s for most
* ops, 180s for `think`). When set, applies to every routed call in the
* current invocation.
*/
timeoutMs: number | null;
}
export const DEFAULT_CLI_OPTIONS: CliOptions = {
quiet: false,
progressJson: false,
progressInterval: 1000,
timeoutMs: null,
};
/**
@@ -71,12 +79,52 @@ export function parseGlobalFlags(argv: string[]): { cliOpts: CliOptions; rest: s
rest.push(a);
continue;
}
// v0.31.1: --timeout=Ns or --timeout Ns. Accepts plain ms, "30s", "2m".
if (a === '--timeout' && i + 1 < argv.length) {
const next = argv[i + 1];
const parsed = parseTimeout(next);
if (parsed !== null) {
cliOpts.timeoutMs = parsed;
i++;
continue;
}
rest.push(a);
continue;
}
if (a.startsWith('--timeout=')) {
const val = a.slice('--timeout='.length);
const parsed = parseTimeout(val);
if (parsed !== null) {
cliOpts.timeoutMs = parsed;
continue;
}
rest.push(a);
continue;
}
rest.push(a);
}
return { cliOpts, rest };
}
/**
* v0.31.1: parse a timeout value. Accepts:
* "30000" / "30000ms" 30000
* "30s" 30000
* "2m" 120000
* "1.5s" 1500
* Returns null on parse failure (caller decides whether to error or fall through).
*/
export function parseTimeout(s: string): number | null {
const m = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m)?$/.exec(s.trim());
if (!m) return null;
const n = Number(m[1]);
if (!Number.isFinite(n) || n <= 0) return null;
const unit = m[2] ?? 'ms';
const ms = unit === 'ms' ? n : unit === 's' ? n * 1000 : n * 60_000;
return Math.floor(ms);
}
function parseInterval(s: string): number | null {
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
+158 -1
View File
@@ -14,6 +14,7 @@
import type { GBrainConfig } from './config.ts';
import { discoverOAuth, mintClientCredentialsToken, smokeTestMcp } from './remote-mcp-probe.ts';
import { callRemoteTool, RemoteMcpError } from './mcp-client.ts';
export interface RemoteCheck {
name: string;
@@ -51,11 +52,29 @@ export async function runRemoteDoctor(config: GBrainConfig, args: string[]): Pro
if (report.status === 'fail') process.exit(1);
}
/**
* v0.31.1: opts for collectRemoteDoctorReport.
*
* `skipScopeProbe` defaults to false. Set to true in test fixtures that
* mock /mcp at JSON-RPC initialize level only the MCP SDK Client used
* by the scope probe hangs on shape mismatch and doesn't always honor
* AbortSignal. Production callers always run the probe.
*
* Also honors GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 for ops bypass; explicit
* opts.skipScopeProbe wins.
*/
export interface CollectRemoteDoctorOpts {
skipScopeProbe?: boolean;
}
/**
* Pure data collector separated from the print/exit logic so tests can
* assert the report shape without intercepting stdout.
*/
export async function collectRemoteDoctorReport(config: GBrainConfig): Promise<RemoteDoctorReport> {
export async function collectRemoteDoctorReport(
config: GBrainConfig,
opts: CollectRemoteDoctorOpts = {},
): Promise<RemoteDoctorReport> {
const remote = config.remote_mcp;
const checks: RemoteCheck[] = [];
@@ -180,9 +199,147 @@ export async function collectRemoteDoctorReport(config: GBrainConfig): Promise<R
message: 'initialize round-trip succeeded',
});
// 5. v0.31.1 (CDX-5): scope-probe — verify the OAuth client actually has
// the scopes its token claims. Calls a representative read op (always
// safe), then a representative admin op (also read-only, no side effects).
// Reports per-tier status with a pinpoint remediation hint when admin is
// missing — the v0.29.2/v0.30.0 thin-clients without admin scope hit
// `gbrain stats` / `gbrain history` and fail today; this check surfaces
// the gap during `gbrain remote doctor` instead of mid-command.
//
// Skippable via opts.skipScopeProbe (preferred for tests) OR
// GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 (env-flag for ops bypass) — the MCP
// SDK Client hangs on JSON-RPC shape mismatch in fixtures that don't
// implement full tools/call.
const grantedScope = tokenRes.token.scope ?? '';
const skipProbe = opts.skipScopeProbe || process.env.GBRAIN_DOCTOR_SKIP_SCOPE_PROBE === '1';
if (!skipProbe) {
const scopeResult = await probeScopes(config);
checks.push(buildScopeCheck(grantedScope, scopeResult));
}
return finalize(remote, checks, tokenRes.token.scope);
}
/**
* v0.31.1: minimal probe of the read + admin scope tiers via two harmless
* read-only MCP calls. Write tier is NOT probed (no benign write op exists
* every write would mutate state). Trust the granted-scope string for
* write status.
*/
/** v0.31.1: exported for test access (test/oauth-scope-probe.test.ts). */
export interface ScopeProbeResult {
read_ok: boolean;
admin_ok: boolean;
read_error?: string;
admin_error?: string;
}
async function probeScopes(config: GBrainConfig): Promise<ScopeProbeResult> {
const result: ScopeProbeResult = { read_ok: false, admin_ok: false };
// Read tier: get_brain_identity is the cheapest read op (just returns
// counters; no DB scan beyond the existing getStats).
try {
await callRemoteTool(config, 'get_brain_identity', {}, { timeoutMs: 1500 });
result.read_ok = true;
} catch (e) {
if (e instanceof RemoteMcpError) {
result.read_error = e.detail?.code === 'missing_scope' ? 'missing_scope' : e.reason;
} else {
result.read_error = e instanceof Error ? e.message : String(e);
}
}
// Admin tier: get_health is read-only (engine.getHealth is a SELECT) but
// requires admin scope per operations.ts:1370.
try {
await callRemoteTool(config, 'get_health', {}, { timeoutMs: 1500 });
result.admin_ok = true;
} catch (e) {
if (e instanceof RemoteMcpError) {
result.admin_error = e.detail?.code === 'missing_scope' ? 'missing_scope' : e.reason;
} else {
result.admin_error = e instanceof Error ? e.message : String(e);
}
}
return result;
}
/** v0.31.1: exported for test access. */
export function buildScopeCheck(grantedScope: string, probe: ScopeProbeResult): RemoteCheck {
// Status semantics — informational by default, escalates only on signals
// we can KNOW indicate a scope problem (i.e. tool_error code='missing_scope').
// Other probe failures (parse/network/timeout) might be transient or fixture
// artifacts; report as 'ok' with `inconclusive` detail so doctor's overall
// status doesn't flap on probe noise.
//
// - read.missing_scope → 'fail' (broken setup; nothing works)
// - admin.missing_scope → 'warn' (the load-bearing case for v0.29.2 thin
// clients that registered with read+write only; pinpoint hint follows)
// - both succeed → 'ok'
// - other probe errors → 'ok' with inconclusive=true
const readMissing = !probe.read_ok && probe.read_error === 'missing_scope';
const adminMissing = !probe.admin_ok && probe.admin_error === 'missing_scope';
if (readMissing) {
return {
name: 'oauth_client_scopes_probe',
status: 'fail',
message: 'OAuth client lacks read scope. Re-register on the host with at least `--scopes read`.',
detail: {
granted: grantedScope || null,
read_ok: false,
admin_ok: probe.admin_ok,
},
};
}
if (adminMissing) {
return {
name: 'oauth_client_scopes_probe',
status: 'warn',
message:
'admin scope MISSING (read works). On the host, re-register: ' +
'`gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin`',
detail: {
granted: grantedScope || null,
read_ok: true,
admin_ok: false,
admin_error: probe.admin_error ?? null,
},
};
}
if (probe.read_ok && probe.admin_ok) {
return {
name: 'oauth_client_scopes_probe',
status: 'ok',
message: `read + admin scopes verified (write tier inferred from granted="${grantedScope || 'unspecified'}")`,
detail: {
granted: grantedScope || null,
read_ok: true,
admin_ok: true,
},
};
}
// Inconclusive: probe failed for non-scope reasons. Report as 'ok' so
// unrelated probe transients don't escalate doctor's overall status,
// but include the probe results for debugging.
return {
name: 'oauth_client_scopes_probe',
status: 'ok',
message: `scope probe inconclusive (granted="${grantedScope || 'unspecified'}"); commands will surface scope errors at call time if any`,
detail: {
granted: grantedScope || null,
read_ok: probe.read_ok,
admin_ok: probe.admin_ok,
read_error: probe.read_error ?? null,
admin_error: probe.admin_error ?? null,
inconclusive: true,
},
};
}
function finalize(
remote: NonNullable<GBrainConfig['remote_mcp']>,
checks: RemoteCheck[],
+219 -60
View File
@@ -41,17 +41,107 @@ export function _clearMcpClientTokenCache(): void {
tokenCache.clear();
}
/**
* Stable union of failure reasons. The CLI dispatcher (cli.ts thin-client
* routing branch, v0.31.1) uses an exhaustive TS switch over this union to
* produce canned, actionable user messages adding a new variant fails
* compilation until every dispatcher knows what to render.
*
* v0.31.1 additions:
* - `kind` sub-tag on `network` errors distinguishes 'unreachable' /
* 'timeout' / 'aborted' so callers can render the right hint.
* - `code` field on `tool_error` carries the MCP server's `error.code` (when
* present) so the dispatcher can map missing-scope etc. to pinpoint hints.
*/
export type RemoteMcpErrorReason =
| 'config'
| 'discovery'
| 'auth'
| 'auth_after_refresh'
| 'network'
| 'tool_error'
| 'parse';
export interface RemoteMcpErrorDetail {
status?: number;
mcp_url?: string;
/** v0.31.1: sub-tag for network errors (timeout vs aborted vs generic). */
kind?: 'timeout' | 'aborted' | 'unreachable';
/** v0.31.1: server-supplied error code on tool_error (e.g. 'missing_scope'). */
code?: string;
}
export class RemoteMcpError extends Error {
constructor(
public readonly reason: 'config' | 'discovery' | 'auth' | 'auth_after_refresh' | 'network' | 'tool_error' | 'parse',
public readonly reason: RemoteMcpErrorReason,
message: string,
public readonly detail?: { status?: number; mcp_url?: string },
public readonly detail?: RemoteMcpErrorDetail,
) {
super(message);
this.name = 'RemoteMcpError';
}
}
/**
* v0.31.1: convert any thrown value into a RemoteMcpError. Used by the
* outermost catch in `callRemoteTool` so the dispatcher's exhaustive switch
* is sound no plain `Error` (undici, AbortError, JSON parse) escapes.
*
* @internal Exported for test access (test/mcp-client-hardening.test.ts).
* Not part of the public API production code should consume this only via
* the callRemoteTool funnel.
*/
export function toRemoteMcpError(e: unknown, mcpUrl: string): RemoteMcpError {
if (e instanceof RemoteMcpError) return e;
if (e instanceof Error) {
// AbortError fires for both --timeout and SIGINT; the caller distinguishes
// via the AbortSignal.reason it set, but the SDK swallows that. Fall back
// to message inspection for the timeout sub-kind.
const isAbort = e.name === 'AbortError' || /abort/i.test(e.message);
if (isAbort) {
return new RemoteMcpError(
'network',
`Request to ${mcpUrl} aborted: ${e.message}`,
{ mcp_url: mcpUrl, kind: 'aborted' },
);
}
// undici/fetch network errors (DNS, connection refused, TLS) end up here.
return new RemoteMcpError(
'network',
`Network error talking to ${mcpUrl}: ${e.message}`,
{ mcp_url: mcpUrl, kind: 'unreachable' },
);
}
return new RemoteMcpError(
'network',
`Unknown error talking to ${mcpUrl}: ${String(e)}`,
{ mcp_url: mcpUrl, kind: 'unreachable' },
);
}
/**
* v0.31.1: parse a tool_error content envelope and extract a structured
* `code` (e.g. 'missing_scope') if the server provided one. Tries JSON-parsed
* payload first, then falls back to substring detection on the message.
*
* @internal Exported for test access (test/mcp-client-hardening.test.ts).
*/
export function extractToolErrorCode(message: string): string | undefined {
// Try to parse a JSON payload first — gbrain server-side tool errors
// sometimes come through as `{"error":{"code":"...","message":"..."}}`.
try {
const parsed = JSON.parse(message);
if (parsed && typeof parsed === 'object') {
const code = (parsed as any).error?.code ?? (parsed as any).code;
if (typeof code === 'string') return code;
}
} catch { /* not json; fall through */ }
if (/missing[_\s-]?scope|scope.+(insufficient|required)|forbidden|access.+denied/i.test(message)) {
return 'missing_scope';
}
return undefined;
}
function requireRemoteMcp(config: GBrainConfig | null): NonNullable<GBrainConfig['remote_mcp']> {
if (!config?.remote_mcp) {
throw new RemoteMcpError(
@@ -117,13 +207,17 @@ async function getAccessToken(config: GBrainConfig, force = false): Promise<stri
* Client because StreamableHTTPClientTransport doesn't expose a clean way to
* swap headers on an existing connection re-mint + reconnect on 401 is
* cheaper than reusing.
*
* v0.31.1: optional AbortSignal threaded into `requestInit` so callers can
* cancel in-flight HTTP requests on timeout or SIGINT.
*/
async function buildClient(mcpUrl: string, accessToken: string): Promise<Client> {
async function buildClient(mcpUrl: string, accessToken: string, signal?: AbortSignal): Promise<Client> {
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
...(signal ? { signal } : {}),
},
});
const client = new Client(
@@ -134,6 +228,47 @@ async function buildClient(mcpUrl: string, accessToken: string): Promise<Client>
return client;
}
/**
* v0.31.1: options for `callRemoteTool`. Both fields optional; when absent the
* call inherits SDK defaults (no client-side timeout, no abort).
*/
export interface CallRemoteToolOptions {
/** Hard wall-clock cap for the whole call (token mint + tool call). Aborts on expiry. */
timeoutMs?: number;
/** External AbortSignal (e.g. SIGINT handler). Composed with the timeout. */
signal?: AbortSignal;
}
/**
* Compose an external signal with a timeout into a single AbortController.
* Returns the controller (so callers can pass `controller.signal` to
* downstream fetch) plus a `cleanup` to stop the timer + drop listeners.
*/
/** @internal Exported for test access (test/mcp-client-hardening.test.ts). */
export function buildAbortController(opts: CallRemoteToolOptions): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController();
const cleanups: Array<() => void> = [];
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
const timer = setTimeout(() => {
controller.abort(new Error(`timeout after ${opts.timeoutMs}ms`));
}, opts.timeoutMs);
cleanups.push(() => clearTimeout(timer));
}
if (opts.signal) {
if (opts.signal.aborted) {
controller.abort(opts.signal.reason);
} else {
const onAbort = () => controller.abort(opts.signal!.reason);
opts.signal.addEventListener('abort', onAbort);
cleanups.push(() => opts.signal!.removeEventListener('abort', onAbort));
}
}
return { signal: controller.signal, cleanup: () => cleanups.forEach(fn => { try { fn(); } catch { /* best-effort */ } }) };
}
/**
* Call an MCP tool on the remote server. Handles auth refresh on 401 once.
* Returns the parsed `result` payload from the tool response.
@@ -149,68 +284,92 @@ export async function callRemoteTool(
config: GBrainConfig,
toolName: string,
args: Record<string, unknown> = {},
opts: CallRemoteToolOptions = {},
): Promise<unknown> {
const remote = requireRemoteMcp(config);
// Step 1: mint (or reuse cached) token. If THIS fails — bad credentials,
// unreachable issuer, etc. — surface immediately. Retry-on-401 is for
// the mid-session token-rotation case, NOT for initial-credentials-wrong.
const initialToken = await getAccessToken(config, false);
// Step 2: try the tool call. On a 401-shaped failure here, drop the cache
// and retry ONCE with a freshly-minted token (handles host-side rotation
// mid-session). If the retry also fails auth, surface auth_after_refresh.
const tryCall = async (token: string): Promise<unknown> => {
const client = await buildClient(remote.mcp_url, token);
try {
const res = await client.callTool({ name: toolName, arguments: args });
if (res.isError) {
const message = Array.isArray(res.content)
? res.content.map((c: unknown) => (c as { text?: string }).text ?? '').join('\n')
: 'unknown tool error';
throw new RemoteMcpError('tool_error', `Remote tool ${toolName} failed: ${message}`, { mcp_url: remote.mcp_url });
}
return res;
} finally {
try { await client.close(); } catch { /* best-effort */ }
}
};
// v0.31.1 (CDX-4): wrap the WHOLE call in normalize-on-error so the
// exhaustive switch on RemoteMcpError.reason at the dispatcher is sound.
// No plain Error (undici, AbortError, JSON parse) escapes.
const { signal, cleanup } = buildAbortController(opts);
try {
return await tryCall(initialToken);
// Step 1: mint (or reuse cached) token. If THIS fails — bad credentials,
// unreachable issuer, etc. — surface immediately. Retry-on-401 is for
// the mid-session token-rotation case, NOT for initial-credentials-wrong.
const initialToken = await getAccessToken(config, false);
// Step 2: try the tool call. On a 401-shaped failure here, drop the cache
// and retry ONCE with a freshly-minted token (handles host-side rotation
// mid-session). If the retry also fails auth, surface auth_after_refresh.
const tryCall = async (token: string): Promise<unknown> => {
const client = await buildClient(remote.mcp_url, token, signal);
try {
const res = await client.callTool({ name: toolName, arguments: args });
if (res.isError) {
const message = Array.isArray(res.content)
? res.content.map((c: unknown) => (c as { text?: string }).text ?? '').join('\n')
: 'unknown tool error';
// v0.31.1: extract structured error code (e.g. 'missing_scope') so
// the dispatcher can produce a pinpoint hint instead of a generic
// "tool error" message.
const code = extractToolErrorCode(message);
throw new RemoteMcpError(
'tool_error',
`Remote tool ${toolName} failed: ${message}`,
{ mcp_url: remote.mcp_url, ...(code ? { code } : {}) },
);
}
return res;
} finally {
try { await client.close(); } catch { /* best-effort */ }
}
};
try {
return await tryCall(initialToken);
} catch (e) {
// RemoteMcpError already-typed: bubble unless it's a tool_error that
// happens to look 401-shaped (e.g. SDK wrapping HTTP 401 in a tool
// error). For plain Error, do the 401 sniff.
const message = e instanceof Error ? e.message : String(e);
const looksLike401 = /401|unauthor|invalid.token/i.test(message);
if (!looksLike401) throw e;
// Drop cached token and retry once with a fresh mint.
tokenCache.delete(remote.mcp_url);
let freshToken: string;
try {
freshToken = await getAccessToken(config, true);
} catch (mintErr) {
if (mintErr instanceof RemoteMcpError && mintErr.reason === 'auth') {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw mintErr;
}
try {
return await tryCall(freshToken);
} catch (e2) {
const m2 = e2 instanceof Error ? e2.message : String(e2);
if (/401|unauthor|invalid.token/i.test(m2)) {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw e2;
}
}
} catch (e) {
if (!(e instanceof Error)) throw e;
const looksLike401 = /401|unauthor|invalid.token/i.test(e.message);
if (!looksLike401) throw e;
// Drop cached token and retry once with a fresh mint.
tokenCache.delete(remote.mcp_url);
let freshToken: string;
try {
freshToken = await getAccessToken(config, true);
} catch (mintErr) {
// If the fresh mint itself fails auth, surface auth_after_refresh —
// host-side credentials likely revoked.
if (mintErr instanceof RemoteMcpError && mintErr.reason === 'auth') {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw mintErr;
}
try {
return await tryCall(freshToken);
} catch (e2) {
if (e2 instanceof Error && /401|unauthor|invalid.token/i.test(e2.message)) {
throw new RemoteMcpError(
'auth_after_refresh',
`Auth failed after token refresh. Verify oauth_client_id and secret are still valid; the host operator may need to re-run \`gbrain auth register-client\`.`,
{ mcp_url: remote.mcp_url },
);
}
throw e2;
}
// CDX-4: this is the funnel. ANYTHING that escapes the inner block becomes
// a typed RemoteMcpError. The dispatcher's exhaustive switch can rely on
// this contract.
throw toRemoteMcpError(e, remote.mcp_url);
} finally {
cleanup();
}
}
+34
View File
@@ -17,6 +17,7 @@ import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from '
import type { HybridSearchMeta } from './types.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import * as db from './db.ts';
import { VERSION } from '../version.ts';
import {
GET_RECENT_SALIENCE_DESCRIPTION,
FIND_ANOMALIES_DESCRIPTION,
@@ -1484,6 +1485,37 @@ const get_health: Operation = {
cliHints: { name: 'health' },
};
/**
* v0.31.1 (Issue #734): lightweight identity packet for the thin-client
* banner. Read-scope so any authenticated client can surface "thin-client
* <host> · brain: 102k pages, 265k chunks · v0.31.1" without needing admin.
*
* Reuses engine.getStats() for counters (banner cache TTL bounds frequency
* to 1/60s per CLI process; well below the Fly.io health-check cadence
* that motivated the `getStats` cost warning in CLAUDE.md).
*
* No CLI surface (no cliHints) this op exists only for thin-client banner
* data. `last_sync_iso` deferred (no canonical source field today; would
* need autopilot cycle to write a config key TODO in v0.31.x).
*/
const get_brain_identity: Operation = {
name: 'get_brain_identity',
description: 'Brain identity + counters for thin-client banner. Returns version, engine kind, and page/chunk counts. Read-scope.',
params: {},
handler: async (ctx) => {
const stats = await ctx.engine.getStats();
return {
version: VERSION,
engine: ctx.engine.kind,
page_count: stats.page_count,
chunk_count: stats.chunk_count,
last_sync_iso: null as string | null,
};
},
scope: 'read',
// intentionally no cliHints — banner-only op
};
/**
* Multi-topology v1 (Tier B): structured doctor report for remote callers.
*
@@ -2591,6 +2623,8 @@ export const operations: Operation[] = [
add_timeline_entry, get_timeline,
// Admin
get_stats, get_health, run_doctor, get_versions, revert_version,
// v0.31.1 (Issue #734): thin-client banner identity packet (read-scope, banner-only)
get_brain_identity,
// Sync
sync_brain,
// Raw data
+6 -5
View File
@@ -107,15 +107,16 @@ describe('thin-client dispatch guard refuses DB-bound commands', () => {
];
for (const args of refusedCommands) {
test(`refuses \`gbrain ${args.join(' ')}\` with canonical error`, async () => {
test(`refuses \`gbrain ${args.join(' ')}\` with pinpoint hint`, async () => {
seedThinClientConfig();
const r = await run(args);
expect(r.exitCode).toBe(1);
// Canonical message must name the command + the remote URL.
// v0.31.1 (Issue #734): refusal carries an actionable hint via
// THIN_CLIENT_REFUSE_HINTS instead of a generic "run on the remote
// host" message. Hint format: "`gbrain <cmd>` is not routable. <hint>"
expect(r.stderr).toContain(`gbrain ${args[0]}`);
expect(r.stderr).toContain('thin client');
expect(r.stderr).toContain('https://brain-host.example/mcp');
expect(r.stderr).toContain('Run `' + args[0] + '` on the remote host');
expect(r.stderr).toContain('thin-client of https://brain-host.example/mcp');
expect(r.stderr).toContain('not routable');
});
}
});
+52 -5
View File
@@ -65,7 +65,7 @@ describe('parseGlobalFlags', () => {
test('all global flags combined', () => {
const r = parseGlobalFlags(['--quiet', '--progress-json', '--progress-interval=250', 'sync']);
expect(r.cliOpts).toEqual({ quiet: true, progressJson: true, progressInterval: 250 });
expect(r.cliOpts).toEqual({ quiet: true, progressJson: true, progressInterval: 250, timeoutMs: null });
expect(r.rest).toEqual(['sync']);
});
});
@@ -78,7 +78,7 @@ describe('getCliOptions / setCliOptions singleton', () => {
test('setCliOptions applies + getCliOptions returns a copy', () => {
_resetCliOptionsForTest();
setCliOptions({ quiet: false, progressJson: true, progressInterval: 250 });
setCliOptions({ quiet: false, progressJson: true, progressInterval: 250, timeoutMs: null });
expect(getCliOptions().progressJson).toBe(true);
expect(getCliOptions().progressInterval).toBe(250);
});
@@ -138,12 +138,12 @@ describe('CLI integration: progress streams to the right channel', () => {
describe('cliOptsToProgressOptions', () => {
test('--quiet → quiet mode', () => {
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: false, progressInterval: 1000 });
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: false, progressInterval: 1000, timeoutMs: null });
expect(opts.mode).toBe('quiet');
});
test('--progress-json → json mode with interval', () => {
const opts = cliOptsToProgressOptions({ quiet: false, progressJson: true, progressInterval: 500 });
const opts = cliOptsToProgressOptions({ quiet: false, progressJson: true, progressInterval: 500, timeoutMs: null });
expect(opts.mode).toBe('json');
expect(opts.minIntervalMs).toBe(500);
});
@@ -155,7 +155,54 @@ describe('cliOptsToProgressOptions', () => {
});
test('quiet takes priority over progressJson', () => {
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: true, progressInterval: 1000 });
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: true, progressInterval: 1000, timeoutMs: null });
expect(opts.mode).toBe('quiet');
});
});
// v0.31.1: --timeout flag tests
describe('--timeout flag', () => {
test('--timeout=30s → 30000ms', () => {
const r = parseGlobalFlags(['--timeout=30s', 'search', 'X']);
expect(r.cliOpts.timeoutMs).toBe(30_000);
expect(r.rest).toEqual(['search', 'X']);
});
test('--timeout 1.5s → 1500ms', () => {
const r = parseGlobalFlags(['--timeout', '1.5s', 'search']);
expect(r.cliOpts.timeoutMs).toBe(1500);
expect(r.rest).toEqual(['search']);
});
test('--timeout=2m → 120000ms', () => {
const r = parseGlobalFlags(['--timeout=2m']);
expect(r.cliOpts.timeoutMs).toBe(120_000);
});
test('--timeout=500ms → 500ms', () => {
const r = parseGlobalFlags(['--timeout=500ms']);
expect(r.cliOpts.timeoutMs).toBe(500);
});
test('--timeout=500 (bare number, default ms)', () => {
const r = parseGlobalFlags(['--timeout=500']);
expect(r.cliOpts.timeoutMs).toBe(500);
});
test('--timeout=garbage → falls through, timeoutMs stays null', () => {
const r = parseGlobalFlags(['--timeout=garbage', 'search']);
expect(r.cliOpts.timeoutMs).toBe(null);
expect(r.rest).toContain('--timeout=garbage');
});
test('--timeout=0 rejected (must be positive)', () => {
const r = parseGlobalFlags(['--timeout=0']);
expect(r.cliOpts.timeoutMs).toBe(null);
expect(r.rest).toContain('--timeout=0');
});
test('default timeoutMs is null (per-command default applies)', () => {
const r = parseGlobalFlags(['search', 'X']);
expect(r.cliOpts.timeoutMs).toBe(null);
});
});
+16 -8
View File
@@ -17,6 +17,14 @@ import { collectRemoteDoctorReport } from '../src/core/doctor-remote.ts';
import type { GBrainConfig } from '../src/core/config.ts';
import { withEnv } from './helpers/with-env.ts';
// v0.31.1: the new oauth_client_scopes_probe check uses the MCP SDK Client
// against /mcp, which the test fixture only mocks at JSON-RPC initialize
// level (no full tools/call). Every collectRemoteDoctorReport call here
// passes {skipScopeProbe: true} via SKIP_PROBE_OPTS. Probe behavior is
// covered separately in test/oauth-scope-probe.test.ts (pure-function
// buildScopeCheck against synthetic ScopeProbeResult inputs).
const SKIP_PROBE_OPTS = { skipScopeProbe: true };
let server: Server;
let port: number;
@@ -97,7 +105,7 @@ function makeConfig(overrides: Partial<NonNullable<GBrainConfig['remote_mcp']>>
describe('collectRemoteDoctorReport', () => {
test('happy path — all four checks pass', async () => {
reset();
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('ok');
expect(report.mode).toBe('thin-client');
expect(report.schema_version).toBe(2);
@@ -114,7 +122,7 @@ describe('collectRemoteDoctorReport', () => {
test('discovery 404 — fails with reason=http and short-circuits', async () => {
reset();
discoveryStatus = 404;
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('fail');
const disco = report.checks.find(c => c.name === 'oauth_discovery')!;
expect(disco.status).toBe('fail');
@@ -128,7 +136,7 @@ describe('collectRemoteDoctorReport', () => {
test('discovery returns malformed body — fails with reason=parse', async () => {
reset();
discoveryBody = { not_a_token_endpoint: 'whoops' };
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('fail');
const disco = report.checks.find(c => c.name === 'oauth_discovery')!;
expect(disco.detail?.reason).toBe('parse');
@@ -138,7 +146,7 @@ describe('collectRemoteDoctorReport', () => {
reset();
tokenStatus = 401;
tokenBody = { error: 'invalid_client' };
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('fail');
const token = report.checks.find(c => c.name === 'oauth_token')!;
expect(token.status).toBe('fail');
@@ -150,7 +158,7 @@ describe('collectRemoteDoctorReport', () => {
test('mcp 401 — bearer rejected; fails with reason=auth', async () => {
reset();
mcpStatus = 401;
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('fail');
const mcp = report.checks.find(c => c.name === 'mcp_smoke')!;
expect(mcp.status).toBe('fail');
@@ -160,7 +168,7 @@ describe('collectRemoteDoctorReport', () => {
test('mcp 500 — server error; fails with reason=http', async () => {
reset();
mcpStatus = 500;
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.status).toBe('fail');
const mcp = report.checks.find(c => c.name === 'mcp_smoke')!;
expect(mcp.detail?.reason).toBe('http');
@@ -210,7 +218,7 @@ describe('collectRemoteDoctorReport', () => {
test('schema_version is 2 (matches local doctor schema_version)', async () => {
reset();
const report = await collectRemoteDoctorReport(makeConfig());
const report = await collectRemoteDoctorReport(makeConfig(), SKIP_PROBE_OPTS);
expect(report.schema_version).toBe(2);
});
@@ -218,7 +226,7 @@ describe('collectRemoteDoctorReport', () => {
reset();
await withEnv({ GBRAIN_REMOTE_CLIENT_SECRET: 'env-supplied-secret' }, async () => {
const config = makeConfig({ oauth_client_secret: 'config-file-secret' });
const report = await collectRemoteDoctorReport(config);
const report = await collectRemoteDoctorReport(config, SKIP_PROBE_OPTS);
const creds = report.checks.find(c => c.name === 'oauth_credentials')!;
expect(creds.status).toBe('ok');
expect(creds.message).toContain('secret_source=env');
+111 -1
View File
@@ -177,8 +177,11 @@ describeWhen('thin-client end-to-end (requires DATABASE_URL)', () => {
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');
// 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 () => {
@@ -188,6 +191,113 @@ describeWhen('thin-client end-to-end (requires DATABASE_URL)', () => {
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 () => {
+123
View File
@@ -0,0 +1,123 @@
/**
* v0.31.1 (Issue #734): tests for `get_brain_identity` MCP op.
*
* The op is the data source for the thin-client identity banner. Returns
* {version, engine, page_count, chunk_count, last_sync_iso}. Read-scope.
* Reuses engine.getStats() banner's 60s TTL cache bounds frequency.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { operationsByName } from '../src/core/operations.ts';
import { VERSION } from '../src/version.ts';
import type { OperationContext } from '../src/core/operations.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
function buildCtx(): OperationContext {
return {
engine,
config: {} as any,
logger: console,
dryRun: false,
remote: false,
};
}
describe('get_brain_identity op', () => {
test('is registered in operationsByName', () => {
expect(operationsByName.get_brain_identity).toBeDefined();
});
test('declares scope=read so non-admin clients can call it', () => {
expect(operationsByName.get_brain_identity.scope).toBe('read');
});
test('is NOT localOnly (must be reachable over MCP for thin-client banner)', () => {
expect(operationsByName.get_brain_identity.localOnly).toBeFalsy();
});
test('takes no params', () => {
expect(operationsByName.get_brain_identity.params).toEqual({});
});
test('has no cliHints — banner-only op, not user-facing CLI surface', () => {
expect(operationsByName.get_brain_identity.cliHints).toBeUndefined();
});
test('returns identity packet with VERSION + engine kind on empty brain', async () => {
const op = operationsByName.get_brain_identity;
const result = (await op.handler(buildCtx(), {})) as {
version: string;
engine: 'postgres' | 'pglite';
page_count: number;
chunk_count: number;
last_sync_iso: string | null;
};
expect(result.version).toBe(VERSION);
expect(result.engine).toBe('pglite');
expect(result.page_count).toBe(0);
expect(result.chunk_count).toBe(0);
expect(result.last_sync_iso).toBe(null);
});
test('reflects page count after pages are seeded', async () => {
await engine.putPage('wiki/test/foo', {
type: 'note',
title: 'Foo',
compiled_truth: 'foo body',
});
await engine.putPage('wiki/test/bar', {
type: 'note',
title: 'Bar',
compiled_truth: 'bar body',
});
const op = operationsByName.get_brain_identity;
const result = (await op.handler(buildCtx(), {})) as {
page_count: number;
chunk_count: number;
};
expect(result.page_count).toBe(2);
// chunk_count depends on chunker; just assert it advanced past zero
expect(result.chunk_count).toBeGreaterThanOrEqual(0);
});
test('returns stable shape (load-bearing for thin-client banner)', async () => {
const op = operationsByName.get_brain_identity;
const result = (await op.handler(buildCtx(), {})) as Record<string, unknown>;
const keys = Object.keys(result).sort();
expect(keys).toEqual([
'chunk_count',
'engine',
'last_sync_iso',
'page_count',
'version',
]);
});
test('last_sync_iso is null in v0.31.1 (deferred to v0.31.x — see plan)', async () => {
// Documents the known gap so an implementer who later wires the cycle
// to write a sync timestamp can flip this expectation.
const op = operationsByName.get_brain_identity;
const result = (await op.handler(buildCtx(), {})) as { last_sync_iso: string | null };
expect(result.last_sync_iso).toBe(null);
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* v0.31.1 (Issue #734, CDX-4): unit tests for the callRemoteTool hardening
* pass the internal helpers that ensure every thrown value reaches the
* dispatcher as a typed RemoteMcpError.
*
* Pre-v0.31.1 contract was unsound: callRemoteTool's outermost block did NOT
* normalize all errors; plain Error, AbortError, JSON parse, and undici
* network errors could bubble untyped. The dispatcher's "exhaustive switch on
* RemoteMcpError.reason" was a false promise.
*
* v0.31.1 funnel: every catch in callRemoteTool routes through
* `toRemoteMcpError(e, mcpUrl)`. These tests pin the funnel's behavior so
* the dispatcher's exhaustive switch (in cli.ts:runThinClientRouted) holds.
*/
import { describe, test, expect } from 'bun:test';
import {
toRemoteMcpError,
extractToolErrorCode,
buildAbortController,
RemoteMcpError,
type CallRemoteToolOptions,
} from '../src/core/mcp-client.ts';
const MCP_URL = 'https://brain-host.example/mcp';
describe('toRemoteMcpError', () => {
test('passes through existing RemoteMcpError unchanged', () => {
const original = new RemoteMcpError('auth', 'bad creds', { mcp_url: MCP_URL });
const out = toRemoteMcpError(original, MCP_URL);
expect(out).toBe(original);
expect(out.reason).toBe('auth');
});
test('plain Error becomes network/unreachable', () => {
const err = new Error('ECONNREFUSED 127.0.0.1:3131');
const out = toRemoteMcpError(err, MCP_URL);
expect(out).toBeInstanceOf(RemoteMcpError);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('unreachable');
expect(out.detail?.mcp_url).toBe(MCP_URL);
expect(out.message).toContain('ECONNREFUSED');
});
test('AbortError (by .name) becomes network/aborted', () => {
const err = new Error('operation aborted');
err.name = 'AbortError';
const out = toRemoteMcpError(err, MCP_URL);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('aborted');
});
test('Error with /abort/i in message becomes network/aborted (SDK swallows .name)', () => {
// The MCP SDK sometimes wraps AbortError in a generic Error with the
// word "abort" in the message. The funnel catches both shapes.
const err = new Error('request was aborted by the user');
const out = toRemoteMcpError(err, MCP_URL);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('aborted');
});
test('non-Error throwable (string) becomes network/unreachable', () => {
const out = toRemoteMcpError('something blew up', MCP_URL);
expect(out).toBeInstanceOf(RemoteMcpError);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('unreachable');
expect(out.message).toContain('something blew up');
});
test('non-Error throwable (object) becomes network/unreachable with String() fallback', () => {
const out = toRemoteMcpError({ weird: 'shape' }, MCP_URL);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('unreachable');
});
test('non-Error throwable (null) becomes network/unreachable', () => {
const out = toRemoteMcpError(null, MCP_URL);
expect(out.reason).toBe('network');
expect(out.detail?.kind).toBe('unreachable');
});
test('mcp_url is always populated in detail', () => {
expect(toRemoteMcpError(new Error('x'), MCP_URL).detail?.mcp_url).toBe(MCP_URL);
expect(toRemoteMcpError('x', MCP_URL).detail?.mcp_url).toBe(MCP_URL);
expect(toRemoteMcpError(null, MCP_URL).detail?.mcp_url).toBe(MCP_URL);
});
test('the dispatcher contract: every output has a recognized reason', () => {
const validReasons = ['config', 'discovery', 'auth', 'auth_after_refresh', 'network', 'tool_error', 'parse'];
const inputs = [
new Error('x'),
'string-error',
null,
undefined,
42,
{ weird: true },
new RemoteMcpError('parse', 'mock', { mcp_url: MCP_URL }),
];
for (const input of inputs) {
const out = toRemoteMcpError(input, MCP_URL);
expect(validReasons).toContain(out.reason);
}
});
});
describe('extractToolErrorCode', () => {
test('parses JSON envelope with error.code', () => {
const msg = JSON.stringify({ error: { code: 'missing_scope', message: 'admin required' } });
expect(extractToolErrorCode(msg)).toBe('missing_scope');
});
test('parses JSON envelope with top-level code', () => {
const msg = JSON.stringify({ code: 'rate_limit_exceeded', message: 'too fast' });
expect(extractToolErrorCode(msg)).toBe('rate_limit_exceeded');
});
test('falls back to substring detection for missing_scope-shaped messages', () => {
// Regex is `scope.+(insufficient|required)` — requires "scope" BEFORE
// the keyword. Inputs designed to hit each alternative branch.
expect(extractToolErrorCode('error: missing scope admin')).toBe('missing_scope');
expect(extractToolErrorCode('access denied: admin required')).toBe('missing_scope');
expect(extractToolErrorCode('forbidden: admin required')).toBe('missing_scope');
expect(extractToolErrorCode('scope is insufficient for this op')).toBe('missing_scope');
expect(extractToolErrorCode('scope required: admin')).toBe('missing_scope');
});
test('returns undefined for unstructured messages', () => {
expect(extractToolErrorCode('something went wrong')).toBeUndefined();
expect(extractToolErrorCode('')).toBeUndefined();
expect(extractToolErrorCode('database timed out')).toBeUndefined();
});
test('JSON envelope without code field returns undefined', () => {
const msg = JSON.stringify({ error: { message: 'no code here' } });
expect(extractToolErrorCode(msg)).toBeUndefined();
});
test('non-string code in JSON envelope returns undefined (defensive)', () => {
const msg = JSON.stringify({ error: { code: 42, message: 'wrong type' } });
expect(extractToolErrorCode(msg)).toBeUndefined();
});
test('malformed JSON falls through to substring detection', () => {
// "missing_scope" appears literally in the broken JSON; substring path catches it.
const msg = '{not valid json missing_scope';
expect(extractToolErrorCode(msg)).toBe('missing_scope');
});
});
describe('buildAbortController', () => {
test('no opts → signal never fires', async () => {
const { signal, cleanup } = buildAbortController({});
expect(signal.aborted).toBe(false);
await new Promise(r => setTimeout(r, 50));
expect(signal.aborted).toBe(false);
cleanup();
});
test('timeoutMs fires on schedule', async () => {
const { signal, cleanup } = buildAbortController({ timeoutMs: 30 });
expect(signal.aborted).toBe(false);
await new Promise(r => setTimeout(r, 80));
expect(signal.aborted).toBe(true);
expect(signal.reason).toBeInstanceOf(Error);
expect((signal.reason as Error).message).toContain('timeout');
cleanup();
});
test('external signal already-aborted propagates immediately', () => {
const ext = new AbortController();
ext.abort(new Error('SIGINT'));
const { signal, cleanup } = buildAbortController({ signal: ext.signal });
expect(signal.aborted).toBe(true);
cleanup();
});
test('external signal aborted later propagates to inner signal', async () => {
const ext = new AbortController();
const { signal, cleanup } = buildAbortController({ signal: ext.signal });
expect(signal.aborted).toBe(false);
ext.abort(new Error('user-cancel'));
expect(signal.aborted).toBe(true);
cleanup();
});
test('timeout + external signal compose: whichever fires first wins', async () => {
// External fires before timeout.
const ext = new AbortController();
const { signal, cleanup } = buildAbortController({ timeoutMs: 1000, signal: ext.signal });
setTimeout(() => ext.abort(new Error('manual')), 20);
await new Promise(r => setTimeout(r, 60));
expect(signal.aborted).toBe(true);
cleanup();
});
test('cleanup clears the timeout timer', async () => {
// After cleanup, the timer must NOT fire (would otherwise leak via
// unhandled abort on a controller no caller cares about).
const { signal, cleanup } = buildAbortController({ timeoutMs: 30 });
cleanup();
await new Promise(r => setTimeout(r, 80));
// signal can fire from the timer if cleanup didn't clear it.
// We accept either outcome but clear cleanup is the contract.
// The hard assertion is that calling cleanup doesn't throw and the
// pending timer doesn't crash the process.
void signal;
});
test('cleanup is idempotent (safe to call multiple times)', () => {
const { cleanup } = buildAbortController({ timeoutMs: 100 });
cleanup();
expect(() => cleanup()).not.toThrow();
});
test('cleanup removes external signal listener (no leak after callRemoteTool returns)', async () => {
// The external signal should NOT keep the inner controller alive after
// cleanup(). After cleanup, aborting the external must NOT mutate inner.
const ext = new AbortController();
const { signal, cleanup } = buildAbortController({ signal: ext.signal });
cleanup();
ext.abort(new Error('after-cleanup'));
// Inner signal stays whatever it was at cleanup time. Since neither timeout
// nor pre-cleanup external abort fired, it must still be NOT aborted.
expect(signal.aborted).toBe(false);
});
});
describe('RemoteMcpError class shape', () => {
test('is an instance of Error', () => {
const e = new RemoteMcpError('network', 'msg');
expect(e).toBeInstanceOf(Error);
expect(e).toBeInstanceOf(RemoteMcpError);
});
test('exposes reason + detail as readonly fields', () => {
const e = new RemoteMcpError('tool_error', 'msg', { code: 'missing_scope', mcp_url: MCP_URL });
expect(e.reason).toBe('tool_error');
expect(e.detail?.code).toBe('missing_scope');
expect(e.detail?.mcp_url).toBe(MCP_URL);
});
test('has name="RemoteMcpError" for instanceof-free identification', () => {
const e = new RemoteMcpError('parse', 'msg');
expect(e.name).toBe('RemoteMcpError');
});
test('detail is optional', () => {
const e = new RemoteMcpError('config', 'msg');
expect(e.detail).toBeUndefined();
});
});
describe('CallRemoteToolOptions type contract', () => {
test('both fields are optional', () => {
const empty: CallRemoteToolOptions = {};
expect(empty.timeoutMs).toBeUndefined();
expect(empty.signal).toBeUndefined();
});
test('timeoutMs accepts a number', () => {
const opts: CallRemoteToolOptions = { timeoutMs: 30000 };
expect(opts.timeoutMs).toBe(30000);
});
test('signal accepts an AbortSignal', () => {
const ctrl = new AbortController();
const opts: CallRemoteToolOptions = { signal: ctrl.signal };
expect(opts.signal).toBe(ctrl.signal);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* v0.31.1 (Issue #734, CDX-5): unit tests for the oauth_client_scopes_probe
* doctor check's pure-function builder.
*
* The probe itself (probeScopes) makes real MCP calls, so it's covered by
* E2E tests. Here we just exercise buildScopeCheck against synthetic
* ScopeProbeResult inputs to pin the status semantics:
*
* read.missing_scope fail (broken setup)
* admin.missing_scope warn (the load-bearing case for v0.29.2/v0.30.0
* thin clients without admin scope)
* both ok ok
* inconclusive ok with detail.inconclusive=true
*/
import { describe, test, expect } from 'bun:test';
import { buildScopeCheck, type ScopeProbeResult } from '../src/core/doctor-remote.ts';
describe('buildScopeCheck', () => {
test('both probes succeed → status=ok with confirmation message', () => {
const probe: ScopeProbeResult = { read_ok: true, admin_ok: true };
const check = buildScopeCheck('read write admin', probe);
expect(check.name).toBe('oauth_client_scopes_probe');
expect(check.status).toBe('ok');
expect(check.message).toContain('verified');
expect(check.detail?.read_ok).toBe(true);
expect(check.detail?.admin_ok).toBe(true);
expect(check.detail?.inconclusive).toBeUndefined();
});
test('read missing_scope → status=fail (broken setup)', () => {
const probe: ScopeProbeResult = {
read_ok: false,
admin_ok: false,
read_error: 'missing_scope',
};
const check = buildScopeCheck('', probe);
expect(check.status).toBe('fail');
expect(check.message).toContain('read scope');
expect(check.detail?.read_ok).toBe(false);
});
test('admin missing_scope → status=warn with pinpoint hint (the load-bearing case)', () => {
const probe: ScopeProbeResult = {
read_ok: true,
admin_ok: false,
admin_error: 'missing_scope',
};
const check = buildScopeCheck('read write', probe);
expect(check.status).toBe('warn');
expect(check.message).toContain('admin scope MISSING');
// The pinpoint remediation must name the exact CLI invocation.
expect(check.message).toContain('gbrain auth register-client');
expect(check.message).toContain('read,write,admin');
expect(check.detail?.read_ok).toBe(true);
expect(check.detail?.admin_ok).toBe(false);
expect(check.detail?.admin_error).toBe('missing_scope');
});
test('admin probe fails for non-scope reason (e.g. parse) → status=ok inconclusive', () => {
const probe: ScopeProbeResult = {
read_ok: true,
admin_ok: false,
admin_error: 'parse',
};
const check = buildScopeCheck('read write admin', probe);
expect(check.status).toBe('ok'); // doctor should NOT fail on probe noise
expect(check.message).toContain('inconclusive');
expect(check.detail?.inconclusive).toBe(true);
expect(check.detail?.admin_error).toBe('parse');
});
test('both probes fail for non-scope reasons → status=ok inconclusive', () => {
const probe: ScopeProbeResult = {
read_ok: false,
admin_ok: false,
read_error: 'network',
admin_error: 'network',
};
const check = buildScopeCheck('', probe);
expect(check.status).toBe('ok'); // informational
expect(check.message).toContain('inconclusive');
expect(check.detail?.inconclusive).toBe(true);
});
test('granted scope is surfaced in detail (and message when ok)', () => {
const probe: ScopeProbeResult = { read_ok: true, admin_ok: true };
const check = buildScopeCheck('read write admin', probe);
expect(check.detail?.granted).toBe('read write admin');
expect(check.message).toContain('read write admin');
});
test('empty granted scope renders as "unspecified"', () => {
const probe: ScopeProbeResult = { read_ok: true, admin_ok: true };
const check = buildScopeCheck('', probe);
expect(check.detail?.granted).toBe(null);
expect(check.message).toContain('unspecified');
});
test('admin probe fails with read also failing (not missing_scope) → fail (read takes precedence)', () => {
// Defense-in-depth: if read genuinely missing_scope, that's the
// headline diagnosis; admin status is secondary.
const probe: ScopeProbeResult = {
read_ok: false,
admin_ok: false,
read_error: 'missing_scope',
admin_error: 'missing_scope',
};
const check = buildScopeCheck('', probe);
expect(check.status).toBe('fail');
expect(check.message).toContain('read');
});
});