mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* v0.31.1 feat: get_brain_identity MCP op (Issue #734 prep) Lightweight read-scope op that returns {version, engine, page_count, chunk_count, last_sync_iso} for the thin-client identity banner. Reuses engine.getStats() — banner's 60s TTL cache (next commit) bounds frequency to ≤1/60s per CLI process. Banner-only op, no cliHints. Pinned by 9 tests in test/get-brain-identity.test.ts. Part of v0.31.1 fix for #734 (thin-client mode silently routing ~25 CLI commands to empty local PGLite). See plan at ~/.claude/plans/how-to-make-mcp-iterative-liskov.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: harden callRemoteTool error normalization + abort/timeout CDX-4 (Codex outside-voice finding): the previous callRemoteTool let plain Error escape — undici network errors, AbortError, JSON parse failures all bubbled untyped. Plan called for an exhaustive switch on RemoteMcpError.reason at the dispatcher; that contract was unsound. Hardening: - New CallRemoteToolOptions {timeoutMs?, signal?} (4th arg, optional). - buildAbortController composes external signal with timeout into a single signal threaded through the SDK transport's requestInit. - toRemoteMcpError funnel converts ANY thrown value to RemoteMcpError before re-raising; the outermost try/catch guarantees the contract. - RemoteMcpErrorReason exported as a stable union type. - RemoteMcpErrorDetail.kind ('timeout'|'aborted'|'unreachable') sub-tags network errors so the dispatcher can render the right hint. - RemoteMcpErrorDetail.code carries server-supplied error codes on tool_error (e.g. 'missing_scope') for pinpoint refusal hints. - extractToolErrorCode parses JSON envelopes first, falls back to substring detection for legacy server messages. All 13 existing mcp-client tests still pass. Typecheck clean. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: --timeout=Ns CLI flag for thin-client routed calls (ENG-4) New global flag --timeout that accepts ms / s / m / ms-suffix forms ("30s", "2m", "500ms", "500"). Default null = per-command default (30s for most ops, 180s for `think` per ENG-4). Plumbs through to callRemoteTool's AbortController via cliOpts.timeoutMs. Rejection cases (timeoutMs stays null, flag falls through): - --timeout=0 (must be positive) - --timeout=garbage (no parse) Pinned by 8 new tests in test/cli-options.test.ts (total 28 pass). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: thin-client routing seam in cli.ts (CDX-1) The keystone fix for Issue #734. Inserts the routing seam INSIDE the existing op-dispatch path in cli.ts:78-138 (per Codex finding CDX-1) — no parallel `src/core/thin-client/` module. Routing is a ~80-line conditional that runs BEFORE connectEngine() so thin-client installs never open the empty local PGLite. Architecture (CDX-1, CDX-4, ENG-2, ENG-4): - Existing arg parser, image-to-base64 transform, stdin handler, and required-param check run UNCHANGED before the routing branch. Zero duplicated parsers. - New runThinClientRouted(op, params, cfg, cliOpts) calls callRemoteTool with {timeoutMs, signal}; default 180s for `think`, 30s otherwise; --timeout flag overrides. - SIGINT abort threaded into AbortController → exit 130. - Exhaustive TS `never` switch on RemoteMcpError.reason produces canned, actionable user messages per failure mode (ENG-4 contract). - ENG-2 renderer parity: local-engine path runs JSON.parse(JSON.stringify()) on the result before formatResult, killing the Date/bigint/Buffer drift class without per-command renderer audit. - THIN_CLIENT_REFUSE_HINTS table replaces the generic refusal message with pinpoint hints (CDX-5 / cherry-pick A). Adds dream/transcripts/storage to the refused set with their own hints. - localOnly ops on thin-client refuse via refuseThinClient (with hint). Pinned by 14 cli-dispatch-thin-client tests (all pass). Typecheck clean. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: thin-client identity banner (cherry-pick B) Prints "[thin-client → wintermute.fly.dev:3131 · brain: 102k pages, 265k chunks · v0.31.1]" to stderr before each routed command. Kills the "am I empty?" confusion that drove the original Hermes/Neuromancer report against wintermute (102k pages → empty CLI search results). Cache: 60s TTL, in-memory Map keyed by mcp_url so switching hosts via `gbrain init` invalidates cleanly. Cross-process file cache deferred. Suppression: --quiet, GBRAIN_NO_BANNER=1, non-TTY default suppresses unless GBRAIN_BANNER=1 explicitly opts in (clean pipes for shell flows). Failure mode: banner fetch errors swallowed; underlying command runs normally. Banner is observability, never load-bearing. The hardened callRemoteTool will surface the same error class on the actual call if the host is genuinely unreachable. Inline in cli.ts per CDX-1 (no parallel module). _clearIdentityCacheForTest exported as test escape hatch. Backed by the new `get_brain_identity` MCP op (read-scope, banner-only). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: route CLI-only commands with MCP equivalents (salience/anomalies/graph-query/think) These four CLI commands bypass the operation-layer dispatch and call engine methods directly today, so the cli.ts routing seam doesn't catch them. Each gets a thin per-command branch: when isThinClient(cfg), callRemoteTool against the corresponding op; otherwise existing engine path runs unchanged. Mappings: - gbrain salience → get_recent_salience (read scope, 30s timeout) - gbrain anomalies → find_anomalies (read scope, 30s timeout) - gbrain graph-query → traverse_graph (read scope, 30s timeout) - gbrain think → think (write scope, 180s timeout) `think` is a special case: the server's think op intentionally disables --save/--take for remote callers (operations.ts:1103-1135 trust-boundary gate per CLAUDE.md subagent-isolation policy). Thin-client think prints a loud warning when those flags are set so users know what they lose instead of silent ignoring. Documented as v0.31.x policy review in plan. Output format unchanged on both paths — the MCP op handler IS the engine method, so the unpacked tool result has identical shape. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: oauth_client_scopes_probe doctor check (CDX-5) \`gbrain remote doctor\` gains a 5th check that probes the read + admin scope tiers via two harmless read-only MCP calls (get_brain_identity and get_health). Surfaces v0.29.2/v0.30.0 thin-client clients that registered with read+write only and now hit \`gbrain stats\` / \`gbrain history\` and fail mid-flight — instead of failing mid-command, doctor names the exact remediation: On the host: gbrain auth register-client <name> --grant-types client_credentials --scopes read,write,admin Status semantics (informational by default): - read.missing_scope → fail (broken setup) - admin.missing_scope → warn + pinpoint hint (the load-bearing case) - both succeed → ok - non-scope probe errors (parse/network/timeout) → ok with detail.inconclusive=true (doctor's overall status doesn't flap) GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1 env-flag for test fixtures that mock /mcp at JSON-RPC initialize level only (MCP SDK Client hangs on shape mismatch and doesn't always honor AbortSignal — adversarial test behavior we don't want to bake into doctor). Pinned by 8 cases in test/oauth-scope-probe.test.ts (pure-function buildScopeCheck) plus unchanged passing of all 23 doctor-remote tests. CDX-5 from the codex outside-voice review. Keeps host-side \`gbrain auth register-client\` default at \`read\` (no breaking change for existing scrapers); puts the migration burden on the THIN-CLIENT side where it belongs. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 feat: refuse \`takes\`/\`sources\` on thin-client with MCP-tool hints (CDX-2) Per the CDX-2 op-coverage audit: takes and sources are multi-subcommand CLIs with mixed local/routable surface. Their READ subcommands (takes_list, takes_search, sources_list, sources_status) have MCP equivalents — those land in v0.31.x with per-subcommand splits. For v0.31.1, refuse both at the top level with hints naming the MCP tools so agents know exactly which tools to invoke directly. Honest framing per CDX-2: "thin-client gbrain routes the read+write+admin op surface; multi-subcommand CLIs land incrementally." Per-subcommand routing recorded as v0.31.x TODO in the plan. Storage is also refused (filesystem-bound; no remote equivalent). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 docs + version: bump VERSION/package.json, CHANGELOG, TODOS, CLAUDE.md Cross-cut for v0.31.1 ship: - VERSION: 0.30.0 → 0.31.1 - package.json: "version": "0.31.1" (bun install refreshed bun.lock) - CHANGELOG.md: full release-summary entry per CLAUDE.md voice contract (numbers-that-matter table with before/after comparison, what-this-means closer, take-advantage block with exact remediation commands, itemized changes by surface, contributor section with plan/decision-history pointer) - TODOS.md: 7 follow-up entries for v0.31.x (timing telemetry, job-routing, per-subcommand takes/sources split, transcripts privacy decision, trust-boundary policy review, register-client default flip, cross-process token cache, parity test backfill) - CLAUDE.md: new "Thin-client routing" section under "Key files" annotating every changed/new file with its v0.31.1 contract — src/cli.ts routing seam, src/core/mcp-client.ts hardening, src/core/cli-options.ts --timeout, src/core/doctor-remote.ts scope-probe, get_brain_identity op, per-command routing in salience/anomalies/graph-query/think. Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 fix: collectRemoteDoctorReport opts.skipScopeProbe + regen llms.txt Replaces the env-var GBRAIN_DOCTOR_SKIP_SCOPE_PROBE module-mutation in test/doctor-remote.test.ts with an explicit opts arg threaded through collectRemoteDoctorReport(config, opts). Satisfies the test-isolation lint (rule R1: no process.env.X = ... in non-serial unit files). Production callers still honor the env-flag for ops bypass; opts wins when both are set. Also regenerates llms.txt + llms-full.txt to match the v0.31.1 CLAUDE.md additions (build:llms drift check passes). Part of v0.31.1 fix for #734. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 test: close coverage gaps — issue #734 e2e regression + CDX-4 hardening unit tests Two real gaps the prior coverage missed: 1. **Issue #734 regression e2e** (test/e2e/thin-client.test.ts +6 cases): Existing e2e covered init/doctor/sync-refusal/remote-ping/no-admin but never exercised the actual bug — `gbrain search` against a populated host. Added the load-bearing regression: seed two pages on the host, run thin-client `gbrain search "<unique-token>"`, assert non-zero rows AND seeded slug present in stdout. If this assertion ever fails, #734 has regressed. Plus: routed identity banner verification (GBRAIN_BANNER=1 path), --quiet suppression check, routed put round-trip (write reaches host, visible from host's local engine), routed admin stats (page_count > 0 not 0/0), and pinpoint refuse-hint format for `gbrain sync`. 2. **CDX-4 hardening unit tests** (test/mcp-client-hardening.test.ts +31 cases): pre-fix the hardening pass had ZERO direct unit coverage. The "exhaustive switch on RemoteMcpError.reason" promise depended on toRemoteMcpError actually normalizing every thrown value, but nothing verified that contract. Added: - toRemoteMcpError: passthrough for RemoteMcpError, AbortError → network/aborted, plain Error → network/unreachable, string/object/null non-Error throwables → network/unreachable, mcp_url always populated, contract test that EVERY output has a recognized reason - extractToolErrorCode: JSON envelope (error.code + top-level code), substring fallback for missing-scope-shaped messages, defensive handling of non-string code field, malformed-JSON fallthrough - buildAbortController: timeout fires on schedule, external signal propagates immediately when pre-aborted and lazily when aborted later, timeout + external compose (whichever fires first wins), cleanup is idempotent and removes external listener (no leak) - RemoteMcpError class shape (instanceof Error, reason/detail readonly, name="RemoteMcpError", detail optional) - CallRemoteToolOptions type contract Internal helpers (toRemoteMcpError, extractToolErrorCode, buildAbortController) gain @internal export tags so the test file can import them without going through the SDK transport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 test: move routing tests before remote-ping; fix pre-existing assertion for new refusal format The newly-added routing tests were running AFTER `gbrain remote ping`, which submits a 60s autopilot-cycle and can leave the server in a state where subsequent OAuth probes fail. Moving them before Tier B so they exercise a healthy server. Also updated the existing `sync is refused with canonical thin-client error` test assertion: v0.31.1 changed the refusal format from generic \`thin client\` (with space) to the pinpoint \`thin-client of <url>\` (with hyphen) plus \`not routable\` prefix. The test now asserts both the new format and the pinpoint hint. E2E result: 10 pass / 3 fail. The 3 failures are pre-existing on master (remote-ping timeout, client-without-admin OAuth discovery flake) and not in my diff scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.31.1 fix: scrub banned fork name from new test fixtures (CI privacy gate) CI's check-privacy.sh rejected the v0.31.1 test additions because the unique-token fixture string used the private OpenClaw fork name as a prefix. Replaced with neutral names per CLAUDE.md privacy rule: - test/e2e/thin-client.test.ts: \`wintermute_routing_proof\` → \`host_routing_proof\` (the unique-token marker that proves search results came from the remote brain, not the empty local PGLite). All 6 references updated. - test/mcp-client-hardening.test.ts: \`https://wintermute.fly.dev/mcp\` → \`https://brain-host.example/mcp\` (the synthetic MCP URL used as the toRemoteMcpError second arg). Matches the convention used in the existing test/cli-dispatch-thin-client.test.ts fixture. bun run verify passes; 31/31 hardening tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|