mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.2.0 feat: gbrain connect — one-command Claude Code onboarding from a bearer token (#1683)
* fix: gbrain auth create dropped the name on the bare (no-flag) form Extract parseAuthCreateArgs; only exclude the --takes-holders value from the positional search when the flag is present (rest[takesIdx+1] resolved to rest[0] when takesIdx === -1, silently dropping the name). Add regression test. * feat: gbrain connect — one-command Claude Code onboarding from a bearer token New connect command prints a paste-ready claude-mcp-add block (or --install wires it + smoke-tests the token via a raw-bearer get_brain_identity probe). Direct HTTP MCP, literal-token default, URL normalization, token header-injection guard, --json redaction, execFileSync (no shell). Wired into CLI_ONLY + CLI_ONLY_SELF_HELP + handleCliOnly. 58 unit + 3 PGLite-E2E cases; e2e-test-map updated. * docs: lead CLAUDE_CODE.md with gbrain connect (remote fast path) + README one-liner Regenerate llms-full.txt for the README change. * refactor: pre-landing review fixes for gbrain connect - DRY: single DEFAULT_PROBE_TIMEOUT_MS + shared isAuthErrorMessage predicate - reuse promptLine (shared stdin lifecycle) for the --install confirm - harden redactToken with a Bearer <value> scrub (defense in depth) - +8 tests: orchestrator guard paths, deterministic timeout, invalid --timeout-ms, Bearer-redaction * fix: adversarial-review hardening for gbrain connect - probe: Promise.race the call against a real timer so a stalled connect()/SSE handshake (signal alone doesn't cover it) can't hang --install indefinitely - probe: close transport even if client.connect() throws - parseArgs: reject a missing/flag-shaped value (e.g. --token --install) - block link-local / cloud-metadata hosts (169.254/fe80:/fd00:ec2::254) — keeps localhost + RFC1918 LAN brains working - non-interactive --install now requires --yes - clearer message when --force removed then add failed +8 tests covering each * fix: codex-review P2s for gbrain connect - POSIX single-quote the rendered claude-mcp-add command so a token with shell metacharacters ($(), backticks) can't trigger command substitution on paste - detect IPv4-mapped IPv6 metadata addresses (::ffff:169.254.x.x / ::ffff:a9fe:*) so they don't bypass the link-local guard +3 tests * chore: bump version and changelog (v0.42.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document gbrain connect + connect-probe in CLAUDE.md Key files (v0.42.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: gbrain connect — add codex and perplexity agents --agent codex emits 'codex mcp add ... --bearer-token-env-var GBRAIN_REMOTE_TOKEN' (token read from the env var at runtime, never in Codex config; --install runs it). --agent perplexity prints the URL + token for the Settings → Connectors GUI (no --install). Generalized the command file: AGENT_SPECS table, buildCodexMcpAddArgv, cmdString(binary,argv), binary-generic ConnectDeps (hasBinary/runBinary/env), agent-aware buildConnectBlock/buildJson. +25 tests. * docs: codex + perplexity connect paths (new CODEX.md, README, CHANGELOG, CLAUDE.md) Regenerate llms-full.txt for the CLAUDE.md/README edits. * test: real-CLI E2E for connect — drive actual claude + codex against a live server Adds claude-code + codex cases to connect-bearer.test.ts that run the real 'claude mcp add' / 'codex mcp add' through 'gbrain connect --install' against a live 'gbrain serve --http' (sandboxed HOME/CODEX_HOME), then assert via 'claude mcp get' / 'codex mcp get' that the server registered (and codex's token stays out of config). Skips when the binary is absent. Perplexity is GUI-only so it's print-asserted. Regen llms for the CLAUDE.md note. * docs: perplexity OAuth + serve --bind/--public-url footgun (per Perplexity feedback) PERPLEXITY.md now documents the host-side HTTP setup (gbrain serve --http --bind 0.0.0.0 --public-url, the v0.34 ECONNREFUSED footgun) and the OAuth 2.1 client_credentials path (gbrain auth register-client) alongside the legacy bearer token. The 'connect --agent perplexity' output points at the same bind/public-url requirement + PERPLEXITY.md. * feat: gbrain connect --oauth — client-credentials path for perplexity/generic OAuth is the correct path for a third-party cloud connector (Perplexity): instead of a long-lived full-access bearer token, the connector gets Issuer URL + Client ID + Client Secret and mints short-lived scoped tokens. --oauth --register mints a least-privilege client on the host (shells gbrain auth register-client); --oauth --client-id/--client-secret uses an existing one. Rejected for claude-code/codex (bearer) and with --install. Issuer derived from the mcp-url. New E2E proves the full chain: register → connect --oauth → OAuth discovery → /token client_credentials mint → get_brain_identity tool call against a live server. Docs: PERPLEXITY.md leads with OAuth; README + CLAUDE.md updated; +18 unit cases. * docs: add gbrain connect to INSTALL.md MCP section + link CODEX.md The remote-client onboarding command was documented in README/CLAUDE_CODE/CODEX/ PERPLEXITY but missing from INSTALL.md §3 (the natural 'how do I connect a client' home). Add the one-command connect how-to (claude-code/codex/perplexity) and the missing docs/mcp/CODEX.md link. * fix: connect LEARN_INSTRUCTION names put_page, not CLI-only capture The self-orientation block told a connected agent that `capture` is an available MCP tool. It isn't — `capture` is a CLI-only convenience command; the MCP write tool is `put_page`. An agent that followed the instruction hit "unknown tool". Drop capture; put_page was already in the list. Adds a regression block to connect.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: serve --http surfaces skill-publishing status (banner + nudge) When mcp.publish_skills is OFF, connected agents can search/write but can't call list_skills/get_skill, so the host's skill catalog is invisible to them. The startup banner now shows a Skills: line, and a stderr nudge fires when off with the paste-ready fix. Pure skillPublishStatus() helper, unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: prove the local stdio MCP funnel end-to-end Spawns real `gbrain serve` (stdio) against a freshly init --pglite brain and drives the official MCP SDK client through initialize -> tools/list -> tools/call (get_brain_identity + search). Pins the advertised core-tool set against what the server actually exposes (asserts capture is NOT advertised). This funnel had zero e2e coverage before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: make batch-retry-audit ENOENT case hermetic The 'no-op when audit dir does not exist' case called pruneOldBatchRetryAuditFiles(30) without a GBRAIN_AUDIT_DIR override, so it read the real ~/.gbrain/audit and flaked (kept:1) on any dev machine with a batch-retry-*.jsonl on disk. Point it at a guaranteed-missing temp subdir, matching this file's own hermetic-header contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: two-funnel coding-agent onboarding (Claude Code / Codex) New tutorial docs/tutorials/connect-coding-agent.md: Path A (connect to an existing brain) + Path B (start from nothing, local stdio), the brain-first protocol to paste into CLAUDE.md/AGENTS.md, and the four translatable habits. README gains a 'Quick start: Claude Code or Codex' fork separating lightweight retrieval from the full autonomous install. INSTALL.md shows the one-command wire-up at the standalone CLI section. mcp/CLAUDE_CODE + CODEX cross-link the tutorial + note publish_skills + capture-is-CLI-only. Tutorial promoted to Shipped in the tutorials index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: changelog + regenerated llms (v0.42.2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: CLAUDE.md Key Files annotation for two-funnel onboarding wave Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eefe8b5741
commit
7b0d99adb0
@@ -179,11 +179,17 @@ describe('pruneOldBatchRetryAuditFiles — codex H-8 actual pruning', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('no-op when audit dir does not exist (ENOENT)', () => {
|
||||
const result = pruneOldBatchRetryAuditFiles(30, new Date());
|
||||
// Even without the env override, the function never throws on missing dir.
|
||||
// We just check it returns the empty result without throwing.
|
||||
expect(result).toEqual({ removed: 0, kept: 0 });
|
||||
test('no-op when audit dir does not exist (ENOENT)', async () => {
|
||||
// Point GBRAIN_AUDIT_DIR at a guaranteed-missing subdir of the per-test
|
||||
// tmpDir. Without this override the function reads the real ~/.gbrain/audit,
|
||||
// so the assertion flakes on any dev machine that already has a real
|
||||
// batch-retry-*.jsonl on disk (returns kept:1, not kept:0). Hermetic now,
|
||||
// matching this file's header contract.
|
||||
await withEnv({ GBRAIN_AUDIT_DIR: path.join(tmpDir, 'does-not-exist') }, async () => {
|
||||
const result = pruneOldBatchRetryAuditFiles(30, new Date());
|
||||
// The function never throws on a missing dir; it returns the empty result.
|
||||
expect(result).toEqual({ removed: 0, kept: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, describe } from 'bun:test';
|
||||
import { parseAuthCreateArgs } from '../src/commands/auth.ts';
|
||||
|
||||
describe('parseAuthCreateArgs', () => {
|
||||
test('bare name (no flag) resolves the name — regression for the dropped-name bug', () => {
|
||||
// Pre-fix this returned name='' because rest[takesIdx+1] === rest[0] when
|
||||
// takesIdx === -1, excluding the only positional from the search.
|
||||
expect(parseAuthCreateArgs(['claude-code'])).toEqual({ name: 'claude-code', takesHolders: undefined });
|
||||
});
|
||||
|
||||
test('name + --takes-holders', () => {
|
||||
expect(parseAuthCreateArgs(['claude-code', '--takes-holders', 'world,garry'])).toEqual({
|
||||
name: 'claude-code',
|
||||
takesHolders: ['world', 'garry'],
|
||||
});
|
||||
});
|
||||
|
||||
test('--takes-holders before the name still finds the name', () => {
|
||||
expect(parseAuthCreateArgs(['--takes-holders', 'world', 'claude-code'])).toEqual({
|
||||
name: 'claude-code',
|
||||
takesHolders: ['world'],
|
||||
});
|
||||
});
|
||||
|
||||
test('the takes-holders value is not mistaken for the name', () => {
|
||||
// 'world' is the flag value, 'mybot' is the name.
|
||||
expect(parseAuthCreateArgs(['--takes-holders', 'world', 'mybot']).name).toBe('mybot');
|
||||
});
|
||||
|
||||
test('no name → empty string (caller prints usage)', () => {
|
||||
expect(parseAuthCreateArgs([]).name).toBe('');
|
||||
expect(parseAuthCreateArgs(['--takes-holders', 'world']).name).toBe('');
|
||||
});
|
||||
|
||||
test('takes-holders trims + drops empties', () => {
|
||||
expect(parseAuthCreateArgs(['n', '--takes-holders', ' world , , garry ']).takesHolders).toEqual(['world', 'garry']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,861 @@
|
||||
import { test, expect, describe } from 'bun:test';
|
||||
import {
|
||||
normalizeMcpUrl,
|
||||
isLinkLocalOrMetadata,
|
||||
validateToken,
|
||||
resolveToken,
|
||||
isValidName,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
cmdString,
|
||||
redactToken,
|
||||
buildConnectBlock,
|
||||
buildJson,
|
||||
runConnect,
|
||||
issuerFromMcpUrl,
|
||||
type ConnectDeps,
|
||||
AGENT_IDS,
|
||||
ENV_VAR,
|
||||
DEFAULT_SCOPES,
|
||||
PLACEHOLDER_TOKEN,
|
||||
PLACEHOLDER_SECRET,
|
||||
REDACTED,
|
||||
LEARN_INSTRUCTION,
|
||||
} from '../src/commands/connect.ts';
|
||||
import {
|
||||
classifyProbeError,
|
||||
extractResultText,
|
||||
probeBrainIdentity,
|
||||
type ProbeDeps,
|
||||
} from '../src/core/connect-probe.ts';
|
||||
|
||||
describe('normalizeMcpUrl', () => {
|
||||
test('bare host:port is rejected with a scheme hint', () => {
|
||||
const r = normalizeMcpUrl('brain.example.com:3131');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/https:\/\/brain\.example\.com:3131/);
|
||||
});
|
||||
|
||||
test('localhost:port (no scheme) is rejected too', () => {
|
||||
expect(normalizeMcpUrl('localhost:3131').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('https host without path appends /mcp', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com:3131');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com:3131/mcp' });
|
||||
});
|
||||
|
||||
test('existing /mcp is not doubled', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com/mcp');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
|
||||
});
|
||||
|
||||
test('trailing slash on /mcp/ is tolerated', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com/mcp/');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
|
||||
});
|
||||
|
||||
test('root path becomes /mcp', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com/');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
|
||||
});
|
||||
|
||||
test('uppercase scheme/host + /MCP normalize to lowercase canonical', () => {
|
||||
const r = normalizeMcpUrl('HTTPS://Brain.Example.COM/MCP');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
|
||||
});
|
||||
|
||||
test('a non-/mcp base path errors and suggests the full URL', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com/gbrain');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/\/gbrain\/mcp/);
|
||||
});
|
||||
|
||||
test('credentials in the URL are rejected', () => {
|
||||
expect(normalizeMcpUrl('https://user:pass@brain.example.com/mcp').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('query strings are rejected', () => {
|
||||
expect(normalizeMcpUrl('https://brain.example.com/mcp?key=1').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('fragment is stripped', () => {
|
||||
const r = normalizeMcpUrl('https://brain.example.com/mcp#frag');
|
||||
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
|
||||
});
|
||||
|
||||
test('non-http scheme is rejected', () => {
|
||||
expect(normalizeMcpUrl('ftp://brain.example.com/mcp').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('http on a non-local host warns about plaintext token', () => {
|
||||
const r = normalizeMcpUrl('http://brain.example.com/mcp');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.warning).toMatch(/unencrypted/i);
|
||||
});
|
||||
|
||||
test('http on localhost does not warn', () => {
|
||||
const r = normalizeMcpUrl('http://localhost:3131/mcp');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
test('empty input errors', () => {
|
||||
expect(normalizeMcpUrl('').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('cloud-metadata / link-local hosts are rejected', () => {
|
||||
expect(normalizeMcpUrl('http://169.254.169.254/mcp').ok).toBe(false);
|
||||
expect(normalizeMcpUrl('http://[fe80::1]/mcp').ok).toBe(false);
|
||||
const r = normalizeMcpUrl('http://169.254.169.254/mcp');
|
||||
if (!r.ok) expect(r.error).toMatch(/link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('localhost and RFC1918/LAN hosts are still allowed (self-hosted brains)', () => {
|
||||
expect(normalizeMcpUrl('http://localhost:3131/mcp').ok).toBe(true);
|
||||
expect(normalizeMcpUrl('http://192.168.1.50:3131/mcp').ok).toBe(true);
|
||||
expect(normalizeMcpUrl('https://10.0.0.5/mcp').ok).toBe(true);
|
||||
});
|
||||
|
||||
test('IPv4-mapped IPv6 metadata addresses do not bypass the guard', () => {
|
||||
// dotted and hex (a9fe == 169.254) IPv4-mapped forms
|
||||
expect(isLinkLocalOrMetadata('::ffff:169.254.169.254')).toBe(true);
|
||||
expect(isLinkLocalOrMetadata('::ffff:a9fe:a9fe')).toBe(true);
|
||||
expect(isLinkLocalOrMetadata('[::ffff:169.254.169.254]')).toBe(true);
|
||||
// a normal mapped LAN/public address is not flagged
|
||||
expect(isLinkLocalOrMetadata('::ffff:192.168.1.5')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
test('accepts a normal token', () => {
|
||||
expect(validateToken('gbrain_abc123').ok).toBe(true);
|
||||
});
|
||||
test('rejects empty', () => {
|
||||
expect(validateToken('').ok).toBe(false);
|
||||
expect(validateToken(' ').ok).toBe(false);
|
||||
});
|
||||
test('rejects whitespace (newline = header injection)', () => {
|
||||
expect(validateToken('abc\ndef').ok).toBe(false);
|
||||
expect(validateToken('abc def').ok).toBe(false);
|
||||
expect(validateToken('abc\tdef').ok).toBe(false);
|
||||
});
|
||||
test('rejects control characters', () => {
|
||||
expect(validateToken('abc\x00def').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToken', () => {
|
||||
test('--token flag wins', () => {
|
||||
expect(resolveToken({ tokenFlag: 'tok', env: 'envtok', mode: 'print' })).toEqual({ kind: 'literal', token: 'tok' });
|
||||
});
|
||||
test('env used when no flag', () => {
|
||||
expect(resolveToken({ tokenFlag: null, env: 'envtok', mode: 'install' })).toEqual({ kind: 'literal', token: 'envtok' });
|
||||
});
|
||||
test('print mode without token returns placeholder', () => {
|
||||
expect(resolveToken({ tokenFlag: null, env: null, mode: 'print' })).toEqual({ kind: 'placeholder' });
|
||||
});
|
||||
test('install mode without token errors with a gbrain auth create hint', () => {
|
||||
const r = resolveToken({ tokenFlag: null, env: null, mode: 'install' });
|
||||
expect(r.kind).toBe('error');
|
||||
if (r.kind === 'error') {
|
||||
expect(r.error).toMatch(/gbrain auth create/);
|
||||
expect(r.error).toMatch(ENV_VAR);
|
||||
}
|
||||
});
|
||||
test('invalid token errors even in print mode', () => {
|
||||
expect(resolveToken({ tokenFlag: 'bad tok', env: null, mode: 'print' }).kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidName', () => {
|
||||
test('accepts conservative identifiers', () => {
|
||||
expect(isValidName('gbrain')).toBe(true);
|
||||
expect(isValidName('team-brain_2')).toBe(true);
|
||||
});
|
||||
test('rejects bad names', () => {
|
||||
expect(isValidName('-leading')).toBe(false);
|
||||
expect(isValidName('Has Space')).toBe(false);
|
||||
expect(isValidName('UPPER')).toBe(false);
|
||||
expect(isValidName('')).toBe(false);
|
||||
expect(isValidName('semi;colon')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('argv + command string', () => {
|
||||
test('claude argv shape', () => {
|
||||
expect(buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'TOK' })).toEqual([
|
||||
'mcp', 'add', 'gbrain', '-t', 'http', 'https://h/mcp', '-H', 'Authorization: Bearer TOK',
|
||||
]);
|
||||
});
|
||||
test('codex argv shape — env-var bearer, no token in argv', () => {
|
||||
expect(buildCodexMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', envVar: ENV_VAR })).toEqual([
|
||||
'mcp', 'add', 'gbrain', '--url', 'https://h/mcp', '--bearer-token-env-var', ENV_VAR,
|
||||
]);
|
||||
});
|
||||
test('command string single-quotes the header (paste-safe)', () => {
|
||||
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'TOK' }));
|
||||
expect(cmd).toBe("claude mcp add gbrain -t http https://h/mcp -H 'Authorization: Bearer TOK'");
|
||||
});
|
||||
test('a token with shell metacharacters cannot trigger command substitution on paste', () => {
|
||||
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'gbrain_$(touch /tmp/pwned)`x`' }));
|
||||
// Single-quoted → the $() and backticks are inert literals, not double-quoted.
|
||||
expect(cmd).toContain("'Authorization: Bearer gbrain_$(touch /tmp/pwned)`x`'");
|
||||
expect(cmd).not.toContain('"Authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactToken', () => {
|
||||
test('replaces every occurrence', () => {
|
||||
expect(redactToken('a TOK b TOK', 'TOK')).toBe(`a ${REDACTED} b ${REDACTED}`);
|
||||
});
|
||||
test('null token still scrubs Bearer-shaped values (defense in depth)', () => {
|
||||
// Even without the literal token, a transformed Bearer echo is scrubbed.
|
||||
expect(redactToken('failed: Bearer gbrain_xyz123', null)).toBe(`failed: Bearer ${REDACTED}`);
|
||||
});
|
||||
test('Bearer scrub catches a non-exact token echo', () => {
|
||||
expect(redactToken('add failed near Bearer SOMETHINGELSE', 'tok')).toContain(`Bearer ${REDACTED}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConnectBlock', () => {
|
||||
test('claude-code with a literal token inlines it + learn instruction', () => {
|
||||
const block = buildConnectBlock({ agent: 'claude-code', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
|
||||
expect(block).toContain("claude mcp add gbrain -t http https://h/mcp -H 'Authorization: Bearer TOK'");
|
||||
expect(block).toContain(LEARN_INSTRUCTION);
|
||||
expect(block).not.toContain(PLACEHOLDER_TOKEN);
|
||||
expect(block).toMatch(/long-lived, full-access secret/);
|
||||
});
|
||||
test('claude-code without a token emits a placeholder + replace hint', () => {
|
||||
const block = buildConnectBlock({ agent: 'claude-code', name: 'gbrain', url: 'https://h/mcp', token: null });
|
||||
expect(block).toContain(PLACEHOLDER_TOKEN);
|
||||
expect(block).toMatch(/gbrain auth create/);
|
||||
});
|
||||
test('generic agent emits URL + header lines, no claude command', () => {
|
||||
const block = buildConnectBlock({ agent: 'generic', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
|
||||
expect(block).toContain('URL: https://h/mcp');
|
||||
expect(block).toContain('Authorization: Bearer TOK');
|
||||
expect(block).not.toContain('claude mcp add');
|
||||
expect(block).toContain(LEARN_INSTRUCTION);
|
||||
});
|
||||
test('codex emits the codex command + env-var export, token only in export', () => {
|
||||
const block = buildConnectBlock({ agent: 'codex', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
|
||||
expect(block).toContain('codex mcp add gbrain --url https://h/mcp --bearer-token-env-var GBRAIN_REMOTE_TOKEN');
|
||||
expect(block).toContain('export GBRAIN_REMOTE_TOKEN=TOK');
|
||||
// the codex command itself must not carry the token
|
||||
expect(block).toMatch(/codex mcp add[^\n]*$/m);
|
||||
expect(block).toContain(LEARN_INSTRUCTION);
|
||||
expect(block).toMatch(/reads the token from \$GBRAIN_REMOTE_TOKEN/);
|
||||
});
|
||||
test('codex single-quotes a metachar token in the export line', () => {
|
||||
const block = buildConnectBlock({ agent: 'codex', name: 'gbrain', url: 'https://h/mcp', token: 'gbrain_$(x)`y`' });
|
||||
expect(block).toContain("export GBRAIN_REMOTE_TOKEN='gbrain_$(x)`y`'");
|
||||
});
|
||||
test('perplexity emits GUI connector steps with URL + token, no CLI command', () => {
|
||||
const block = buildConnectBlock({ agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
|
||||
expect(block).toMatch(/Settings.+Connectors/);
|
||||
expect(block).toContain('URL: https://h/mcp');
|
||||
expect(block).toContain('Token: TOK');
|
||||
expect(block).not.toContain('mcp add');
|
||||
expect(block).toContain(LEARN_INSTRUCTION);
|
||||
// surfaces the v0.34 remote-reachability footgun (serve --bind 0.0.0.0)
|
||||
expect(block).toContain('--bind 0.0.0.0');
|
||||
expect(block).toMatch(/docs\/mcp\/PERPLEXITY\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildJson', () => {
|
||||
test('redacts the token by default; claude has a command', () => {
|
||||
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: 'SeKrEt9', showToken: false });
|
||||
expect(j.token_present).toBe(true);
|
||||
expect(j.token_redacted).toBe(true);
|
||||
expect(j.env_var).toBe(ENV_VAR);
|
||||
expect(typeof j.command).toBe('string');
|
||||
expect(Array.isArray(j.command_argv)).toBe(true);
|
||||
expect(JSON.stringify(j)).not.toContain('SeKrEt9');
|
||||
expect(JSON.stringify(j)).toContain(REDACTED);
|
||||
});
|
||||
test('--show-token reveals the literal token', () => {
|
||||
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: 'SeKrEt9', showToken: true });
|
||||
expect(j.token_redacted).toBe(false);
|
||||
expect(JSON.stringify(j)).toContain('Authorization: Bearer SeKrEt9');
|
||||
});
|
||||
test('no token → placeholder, token_present false', () => {
|
||||
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: null, showToken: false });
|
||||
expect(j.token_present).toBe(false);
|
||||
expect(JSON.stringify(j)).toContain(PLACEHOLDER_TOKEN);
|
||||
});
|
||||
test('codex command carries the env-var name, never the token (even with --show-token)', () => {
|
||||
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'codex', token: 'SeKrEt9', showToken: true });
|
||||
expect(j.command).toContain('--bearer-token-env-var GBRAIN_REMOTE_TOKEN');
|
||||
expect(j.command).not.toContain('SeKrEt9'); // token is in the env-var, not the command
|
||||
expect(j.header).toContain('Authorization: Bearer SeKrEt9'); // header field carries it under --show-token
|
||||
});
|
||||
test('perplexity has no runnable command', () => {
|
||||
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: 'TOK', showToken: false });
|
||||
expect(j.command).toBeNull();
|
||||
expect(j.command_argv).toBeNull();
|
||||
expect(j.header).toContain('Authorization: Bearer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth helpers', () => {
|
||||
test('issuerFromMcpUrl strips /mcp', () => {
|
||||
expect(issuerFromMcpUrl('https://brain.example.com:3131/mcp')).toBe('https://brain.example.com:3131');
|
||||
expect(issuerFromMcpUrl('https://brain.example.com/mcp')).toBe('https://brain.example.com');
|
||||
});
|
||||
|
||||
test('perplexity oauth block: issuer + client id/secret, no bearer header', () => {
|
||||
const block = buildConnectBlock({
|
||||
agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: null,
|
||||
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_y' },
|
||||
});
|
||||
expect(block).toMatch(/Settings.+Connectors/);
|
||||
expect(block).toContain('Issuer URL: https://h');
|
||||
expect(block).toContain('Client ID: gbrain_cl_x');
|
||||
expect(block).toContain('Client Secret: gbrain_cs_y');
|
||||
expect(block).toContain('OAuth 2.1 (client credentials)');
|
||||
expect(block).not.toContain('Authorization: Bearer');
|
||||
expect(block).toContain(LEARN_INSTRUCTION);
|
||||
});
|
||||
|
||||
test('generic oauth block emits the OAuth fields', () => {
|
||||
const block = buildConnectBlock({
|
||||
agent: 'generic', name: 'gbrain', url: 'https://h/mcp', token: null,
|
||||
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_y' },
|
||||
});
|
||||
expect(block).toContain('Issuer URL: https://h');
|
||||
expect(block).toContain('Client ID: gbrain_cl_x');
|
||||
expect(block).toContain('Client Secret: gbrain_cs_y');
|
||||
});
|
||||
|
||||
test('oauth block placeholders a missing secret', () => {
|
||||
const block = buildConnectBlock({
|
||||
agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: null,
|
||||
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: null },
|
||||
});
|
||||
expect(block).toContain(PLACEHOLDER_SECRET);
|
||||
});
|
||||
|
||||
test('buildJson oauth: redacts the secret by default, exposes issuer + scopes', () => {
|
||||
const j = buildJson({
|
||||
url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: null, showToken: false,
|
||||
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'SeKrEt9' }, scopes: 'read',
|
||||
});
|
||||
expect(j.auth).toBe('oauth');
|
||||
expect(j.issuer_url).toBe('https://h');
|
||||
expect(j.client_id).toBe('gbrain_cl_x');
|
||||
expect(j.client_secret).toBe(REDACTED);
|
||||
expect(j.secret_redacted).toBe(true);
|
||||
expect(j.scopes).toBe('read');
|
||||
expect(j.command).toBeNull();
|
||||
expect(JSON.stringify(j)).not.toContain('SeKrEt9');
|
||||
});
|
||||
|
||||
test('buildJson oauth --show-token reveals the secret', () => {
|
||||
const j = buildJson({
|
||||
url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: null, showToken: true,
|
||||
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'SeKrEt9' },
|
||||
});
|
||||
expect(j.client_secret).toBe('SeKrEt9');
|
||||
expect(j.scopes).toBe(DEFAULT_SCOPES);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connect-probe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('classifyProbeError', () => {
|
||||
test('timeout/abort', () => {
|
||||
expect(classifyProbeError('timeout after 15000ms')).toBe('timeout');
|
||||
expect(classifyProbeError('The operation was aborted')).toBe('timeout');
|
||||
});
|
||||
test('auth', () => {
|
||||
expect(classifyProbeError('HTTP 401 Unauthorized')).toBe('auth');
|
||||
expect(classifyProbeError('403 forbidden')).toBe('auth');
|
||||
});
|
||||
test('unreachable', () => {
|
||||
expect(classifyProbeError('fetch failed')).toBe('unreachable');
|
||||
expect(classifyProbeError('getaddrinfo ENOTFOUND brain.example.com')).toBe('unreachable');
|
||||
expect(classifyProbeError('connect ECONNREFUSED 127.0.0.1:3131')).toBe('unreachable');
|
||||
// MCP SDK / undici friendly wrapper for a refused connection.
|
||||
expect(classifyProbeError('Unable to connect. Is the computer able to access the url?')).toBe('unreachable');
|
||||
});
|
||||
test('unknown fallback', () => {
|
||||
expect(classifyProbeError('something weird')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractResultText', () => {
|
||||
test('joins text content entries', () => {
|
||||
expect(extractResultText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb');
|
||||
});
|
||||
test('non-array → empty', () => {
|
||||
expect(extractResultText(null)).toBe('');
|
||||
expect(extractResultText({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeBrainIdentity (injected deps)', () => {
|
||||
test('ok result extracts identity text', async () => {
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: async () => ({ content: [{ type: 'text', text: 'brain: alice-example' }] }),
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
|
||||
expect(r).toEqual({ ok: true, identity: 'brain: alice-example' });
|
||||
});
|
||||
test('isError with 401 → auth', async () => {
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: async () => ({ isError: true, content: [{ type: 'text', text: 'HTTP 401' }] }),
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('auth');
|
||||
});
|
||||
test('thrown ENOTFOUND → unreachable', async () => {
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: async () => { throw new Error('getaddrinfo ENOTFOUND h'); },
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('unreachable');
|
||||
});
|
||||
test('isError with a non-auth message → tool_error', async () => {
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: async () => ({ isError: true, content: [{ type: 'text', text: 'tool blew up: bad arguments' }] }),
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('tool_error');
|
||||
});
|
||||
test('timeout timer fires → reason timeout (deterministic, no real sleep)', async () => {
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: (_u, _t, signal) => new Promise((_res, rej) => {
|
||||
signal.addEventListener('abort', () => rej(new Error('The operation was aborted')));
|
||||
}),
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { timeoutMs: 10, deps });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('timeout');
|
||||
});
|
||||
test('a connectAndCall that ignores the abort signal still times out (Promise.race)', async () => {
|
||||
// Simulates a transport whose connect()/SSE handshake never honors the
|
||||
// signal — the probe must still resolve via the timeout race, not hang.
|
||||
const deps: ProbeDeps = {
|
||||
connectAndCall: () => new Promise(() => { /* never settles, ignores signal */ }),
|
||||
};
|
||||
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { timeoutMs: 15, deps });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runConnect orchestrator (install path) — inject deps, stub process.exit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function captureConsole() {
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
console.log = (...a: unknown[]) => { out.push(a.join(' ')); };
|
||||
console.error = (...a: unknown[]) => { err.push(a.join(' ')); };
|
||||
return {
|
||||
out, err,
|
||||
restore() { console.log = origLog; console.error = origErr; },
|
||||
};
|
||||
}
|
||||
|
||||
async function runWithExitCapture(args: string[], deps: ConnectDeps): Promise<{ exitCode?: number; out: string[]; err: string[] }> {
|
||||
const cap = captureConsole();
|
||||
const origExit = process.exit;
|
||||
let exitCode: number | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.exit = ((c?: number) => { exitCode = c ?? 0; throw new Error('__EXIT__'); }) as any;
|
||||
try {
|
||||
await runConnect(args, deps);
|
||||
} catch (e) {
|
||||
if ((e as Error).message !== '__EXIT__') { cap.restore(); process.exit = origExit; throw e; }
|
||||
} finally {
|
||||
cap.restore();
|
||||
process.exit = origExit;
|
||||
}
|
||||
return { exitCode, out: cap.out, err: cap.err };
|
||||
}
|
||||
|
||||
function installDeps(over: Partial<ConnectDeps> = {}): ConnectDeps {
|
||||
return {
|
||||
isTTY: () => false,
|
||||
promptYesNo: async () => true,
|
||||
hasBinary: () => true,
|
||||
runBinary: (_binary, argv) => (argv[1] === 'get' ? { code: 1, stdout: '', stderr: '' } : { code: 0, stdout: '', stderr: '' }),
|
||||
probe: async () => ({ ok: true, identity: 'brain: alice-example' }),
|
||||
env: () => undefined, // tests control the env; real GBRAIN_REMOTE_TOKEN must not leak in
|
||||
registerOAuthClient: () => ({ ok: true, clientId: 'gbrain_cl_minted', clientSecret: 'gbrain_cs_minted' }),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runConnect --install', () => {
|
||||
test('happy path: adds server, verifies, prints learn instruction', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.err.join('\n')).toMatch(/Added MCP server 'gbrain'/);
|
||||
expect(r.err.join('\n')).toMatch(/Verified/);
|
||||
expect(r.err.join('\n')).toContain(LEARN_INSTRUCTION);
|
||||
});
|
||||
|
||||
test('probe failure warns + exits 1 + never echoes the token', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes'],
|
||||
installDeps({ probe: async () => ({ ok: false, reason: 'auth', message: 'HTTP 401 for gbrain_secret' }) }),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const all = [...r.out, ...r.err].join('\n');
|
||||
expect(all).toMatch(/did not verify \(auth\)/);
|
||||
expect(all).not.toContain('gbrain_secret');
|
||||
expect(all).toContain(REDACTED);
|
||||
});
|
||||
|
||||
test('missing claude binary fails fast', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes'],
|
||||
installDeps({ hasBinary: () => false }),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/not found on PATH/);
|
||||
});
|
||||
|
||||
test('existing server name without --force is refused', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes'],
|
||||
installDeps({ runBinary: () => ({ code: 0, stdout: '', stderr: '' }) }), // get returns 0 → exists
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/already exists/);
|
||||
});
|
||||
|
||||
test('install without a token errors with the auth-create hint', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--install', '--yes'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/gbrain auth create/);
|
||||
});
|
||||
|
||||
test('--force replaces an existing server then verifies', async () => {
|
||||
const calls: string[][] = [];
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--force'],
|
||||
installDeps({
|
||||
runBinary: (_b, argv) => { calls.push(argv); return { code: 0, stdout: '', stderr: '' }; }, // get→0 (exists), remove→0, add→0
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(calls.some((a) => a[1] === 'remove')).toBe(true);
|
||||
expect(calls.some((a) => a[1] === 'add')).toBe(true);
|
||||
expect(r.err.join('\n')).toMatch(/Added MCP server/);
|
||||
});
|
||||
|
||||
test('--force remove failure aborts + redacts the token', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes', '--force'],
|
||||
installDeps({
|
||||
runBinary: (_b, argv) => (argv[1] === 'remove'
|
||||
? { code: 1, stdout: '', stderr: 'remove failed near gbrain_secret' }
|
||||
: { code: 0, stdout: '', stderr: '' }),
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const all = [...r.out, ...r.err].join('\n');
|
||||
expect(all).toMatch(/Could not replace/);
|
||||
expect(all).not.toContain('gbrain_secret');
|
||||
});
|
||||
|
||||
test('claude mcp add failure aborts + redacts the token', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes'],
|
||||
installDeps({
|
||||
runBinary: (_b, argv) => (argv[1] === 'add'
|
||||
? { code: 1, stdout: '', stderr: 'add blew up with gbrain_secret' }
|
||||
: { code: 1, stdout: '', stderr: '' }), // get→1 (not exists)
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const all = [...r.out, ...r.err].join('\n');
|
||||
expect(all).toMatch(/'claude mcp add' failed/);
|
||||
expect(all).not.toContain('gbrain_secret');
|
||||
});
|
||||
|
||||
test('TTY prompt decline aborts without adding', async () => {
|
||||
const calls: string[][] = [];
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install'], // no --yes, TTY on
|
||||
installDeps({
|
||||
isTTY: () => true,
|
||||
promptYesNo: async () => false,
|
||||
runBinary: (_b, argv) => { calls.push(argv); return { code: argv[1] === 'get' ? 1 : 0, stdout: '', stderr: '' }; },
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/Aborted/);
|
||||
expect(calls.some((a) => a[1] === 'add')).toBe(false);
|
||||
});
|
||||
|
||||
test('--install with --agent generic is rejected', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--agent', 'generic'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/--install supports claude-code and codex/);
|
||||
});
|
||||
|
||||
test('--install with --agent perplexity is rejected (GUI connector)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--agent', 'perplexity'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/Perplexity Computer is set up through its own UI/);
|
||||
});
|
||||
|
||||
test('--agent codex --install runs the codex CLI and hints the env var', async () => {
|
||||
const calls: Array<{ binary: string; argv: string[] }> = [];
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes', '--agent', 'codex'],
|
||||
installDeps({
|
||||
runBinary: (binary, argv) => { calls.push({ binary, argv }); return { code: argv[1] === 'get' ? 1 : 0, stdout: '', stderr: '' }; },
|
||||
env: () => undefined, // GBRAIN_REMOTE_TOKEN not set → expect the export hint
|
||||
}),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
// Uses the codex binary with the env-var bearer form (no token in argv).
|
||||
const add = calls.find((c) => c.argv[1] === 'add');
|
||||
expect(add?.binary).toBe('codex');
|
||||
expect(add?.argv).toEqual(['mcp', 'add', 'gbrain', '--url', 'https://brain.example.com/mcp', '--bearer-token-env-var', 'GBRAIN_REMOTE_TOKEN']);
|
||||
expect(JSON.stringify(add?.argv)).not.toContain('gbrain_tok');
|
||||
expect(r.err.join('\n')).toMatch(/export GBRAIN_REMOTE_TOKEN/);
|
||||
expect(r.err.join('\n')).toMatch(/Verified/);
|
||||
});
|
||||
|
||||
test('--agent codex --install skips the env hint when GBRAIN_REMOTE_TOKEN already matches', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes', '--agent', 'codex'],
|
||||
installDeps({ env: (n) => (n === 'GBRAIN_REMOTE_TOKEN' ? 'gbrain_tok' : undefined) }),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.err.join('\n')).not.toMatch(/Add this to your shell profile/);
|
||||
});
|
||||
|
||||
test('non-interactive --install without --yes is refused', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'tok', '--install'], // isTTY false (default), no --yes
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/requires --yes/);
|
||||
});
|
||||
|
||||
test('a flag-shaped --token value is rejected (no silent swallow)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', '--install'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/--token requires a value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runConnect print mode', () => {
|
||||
test('prints the block to stdout with the literal token', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_tok'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.out.join('\n')).toContain("claude mcp add gbrain -t http https://brain.example.com/mcp -H 'Authorization: Bearer gbrain_tok'");
|
||||
});
|
||||
|
||||
test('--json redacts the token', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--json'],
|
||||
installDeps(),
|
||||
);
|
||||
const j = JSON.parse(r.out.join('\n'));
|
||||
expect(j.token_redacted).toBe(true);
|
||||
expect(r.out.join('\n')).not.toContain('gbrain_secret');
|
||||
});
|
||||
|
||||
test('--help prints command-specific HELP, no exit', async () => {
|
||||
const r = await runWithExitCapture(['--help'], installDeps());
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.out.join('\n')).toMatch(/gbrain connect/);
|
||||
});
|
||||
|
||||
test('unknown --agent fails fast', async () => {
|
||||
const r = await runWithExitCapture(['https://h/mcp', '--token', 't', '--agent', 'bogus'], installDeps());
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/Unknown --agent/);
|
||||
});
|
||||
|
||||
test('invalid --name fails fast', async () => {
|
||||
const r = await runWithExitCapture(['https://h/mcp', '--token', 't', '--name', 'Bad Name'], installDeps());
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/Invalid --name/);
|
||||
});
|
||||
|
||||
test('bad URL exits 1 via the orchestrator', async () => {
|
||||
const r = await runWithExitCapture(['brain.example.com:3131', '--token', 't'], installDeps());
|
||||
expect(r.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('http non-local prints the plaintext-token warning but still proceeds', async () => {
|
||||
const r = await runWithExitCapture(['http://brain.example.com/mcp', '--token', 't'], installDeps());
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.err.join('\n')).toMatch(/unencrypted/i);
|
||||
});
|
||||
|
||||
test('invalid --timeout-ms falls back to the default (probe receives it)', async () => {
|
||||
let seen = -1;
|
||||
await runWithExitCapture(
|
||||
['https://h/mcp', '--token', 't', '--install', '--yes', '--timeout-ms', 'abc'],
|
||||
installDeps({ probe: async (_u, _t, ms) => { seen = ms; return { ok: true, identity: 'ok' }; } }),
|
||||
);
|
||||
expect(seen).toBe(15000);
|
||||
});
|
||||
|
||||
test('--agent codex print mode emits the codex block', async () => {
|
||||
const r = await runWithExitCapture(['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'codex'], installDeps());
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
const out = r.out.join('\n');
|
||||
expect(out).toContain('codex mcp add gbrain --url https://brain.example.com/mcp --bearer-token-env-var GBRAIN_REMOTE_TOKEN');
|
||||
expect(out).toContain('export GBRAIN_REMOTE_TOKEN=gbrain_tok');
|
||||
});
|
||||
|
||||
test('--agent perplexity print mode emits GUI connector steps', async () => {
|
||||
const r = await runWithExitCapture(['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'perplexity'], installDeps());
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
expect(r.out.join('\n')).toMatch(/Settings.+Connectors/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGENT_IDS', () => {
|
||||
test('exposes the four supported agents', () => {
|
||||
expect(AGENT_IDS).toEqual(['claude-code', 'codex', 'perplexity', 'generic']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEARN_INSTRUCTION names only real MCP tools', () => {
|
||||
// The self-orientation block is pasted into a connected agent verbatim. Every
|
||||
// tool it names MUST be MCP-exposed, or the agent calls an "unknown tool".
|
||||
// The exposed set is pinned end-to-end by test/e2e/serve-stdio-roundtrip.ts.
|
||||
test('names put_page (the real MCP write tool), not capture (CLI-only)', () => {
|
||||
expect(LEARN_INSTRUCTION).toContain('put_page');
|
||||
// `capture` is a CLI-only convenience wrapper, not an MCP tool — naming it
|
||||
// here told connected agents to call a tool the server does not expose.
|
||||
expect(LEARN_INSTRUCTION).not.toContain('capture');
|
||||
});
|
||||
test('still steers the agent to get_brain_identity + list_skills + brain-first search', () => {
|
||||
expect(LEARN_INSTRUCTION).toContain('get_brain_identity');
|
||||
expect(LEARN_INSTRUCTION).toContain('list_skills');
|
||||
expect(LEARN_INSTRUCTION.toLowerCase()).toContain('search the brain before');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runConnect --oauth', () => {
|
||||
test('perplexity --oauth with BYO client id/secret prints the OAuth connector block', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--client-id', 'gbrain_cl_x', '--client-secret', 'gbrain_cs_y'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
const out = r.out.join('\n');
|
||||
expect(out).toContain('Issuer URL: https://brain.example.com');
|
||||
expect(out).toContain('Client ID: gbrain_cl_x');
|
||||
expect(out).toContain('Client Secret: gbrain_cs_y');
|
||||
});
|
||||
|
||||
test('perplexity --oauth --register mints a client via the host and prints it', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register'],
|
||||
installDeps(), // registerOAuthClient → gbrain_cl_minted / gbrain_cs_minted
|
||||
);
|
||||
expect(r.exitCode).toBeUndefined();
|
||||
const out = r.out.join('\n');
|
||||
expect(out).toContain('Client ID: gbrain_cl_minted');
|
||||
expect(out).toContain('Client Secret: gbrain_cs_minted');
|
||||
});
|
||||
|
||||
test('perplexity --oauth --register --json redacts the secret by default', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register', '--json'],
|
||||
installDeps({ registerOAuthClient: () => ({ ok: true, clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_secret' }) }),
|
||||
);
|
||||
const j = JSON.parse(r.out.join('\n'));
|
||||
expect(j.auth).toBe('oauth');
|
||||
expect(j.client_secret).toBe(REDACTED);
|
||||
expect(r.out.join('\n')).not.toContain('gbrain_cs_secret');
|
||||
});
|
||||
|
||||
test('--oauth without creds or --register fails with guidance', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/--register/);
|
||||
expect(r.err.join('\n')).toMatch(/--client-id/);
|
||||
});
|
||||
|
||||
test('--oauth with only --client-id fails (needs both)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--client-id', 'gbrain_cl_x'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/BOTH --client-id and --client-secret/);
|
||||
});
|
||||
|
||||
test('register failure surfaces the manual register-client command', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register'],
|
||||
installDeps({ registerOAuthClient: () => ({ ok: false, message: 'No database connection' }) }),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/gbrain auth register-client/);
|
||||
});
|
||||
|
||||
test('--oauth is rejected for claude-code (uses bearer)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'claude-code', '--oauth', '--register'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/connector-style/);
|
||||
});
|
||||
|
||||
test('--oauth is rejected for codex (uses bearer)', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'codex', '--oauth', '--register'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/connector-style/);
|
||||
});
|
||||
|
||||
test('--oauth + --install is rejected', async () => {
|
||||
const r = await runWithExitCapture(
|
||||
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register', '--install', '--yes'],
|
||||
installDeps(),
|
||||
);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.err.join('\n')).toMatch(/--install is not supported with --oauth/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* E2E for `gbrain connect`'s D4 raw-bearer smoke probe (connect-probe.ts).
|
||||
*
|
||||
* Spins up a real `gbrain serve --http` against a hermetic PGLite brain (no
|
||||
* Postgres / Docker), mints a legacy bearer token via `gbrain auth create`,
|
||||
* then drives the real MCP SDK probe against `/mcp`:
|
||||
* - real token → ok, returns get_brain_identity payload
|
||||
* - wrong token → not ok, reason 'auth'
|
||||
* - unreachable → not ok, reason 'unreachable' | 'timeout'
|
||||
*
|
||||
* This is the integration coverage the unit tests (injected deps) can't give:
|
||||
* the actual StreamableHTTP initialize handshake + tools/call over bearer auth.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawn, spawnSync, execFileSync, type ChildProcess } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { probeBrainIdentity } from '../../src/core/connect-probe.ts';
|
||||
import { discoverOAuth, mintClientCredentialsToken } from '../../src/core/remote-mcp-probe.ts';
|
||||
|
||||
const PORT = 19735; // avoid the production 3131 + the oauth E2E's 19131
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const MCP_URL = `${BASE}/mcp`;
|
||||
|
||||
describe('connect bearer probe E2E (PGLite + real serve --http)', () => {
|
||||
let home: string;
|
||||
let server: ChildProcess | null = null;
|
||||
let token = '';
|
||||
let oauthClientId = '';
|
||||
let oauthClientSecret = '';
|
||||
let serverReady = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-connect-e2e-'));
|
||||
const env = { ...process.env, GBRAIN_HOME: home };
|
||||
|
||||
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
|
||||
cwd: process.cwd(), env, stdio: 'ignore',
|
||||
});
|
||||
const authOut = execFileSync('bun', ['run', 'src/cli.ts', 'auth', 'create', 'e2e-connect'], {
|
||||
cwd: process.cwd(), env, encoding: 'utf8',
|
||||
});
|
||||
token = (authOut.match(/gbrain_[a-f0-9]{64}/) ?? [''])[0];
|
||||
if (!token) throw new Error(`auth create did not yield a token:\n${authOut}`);
|
||||
|
||||
// Register the OAuth client BEFORE spawning serve — PGLite is single-writer,
|
||||
// so register-client can't open the brain once the server holds it.
|
||||
const regOut = execFileSync('bun', [
|
||||
'run', 'src/cli.ts', 'auth', 'register-client', 'e2e-perplexity-oauth',
|
||||
'--grant-types', 'client_credentials', '--scopes', 'read write',
|
||||
'--token-endpoint-auth-method', 'client_secret_post',
|
||||
], { cwd: process.cwd(), env, encoding: 'utf8' });
|
||||
oauthClientId = (regOut.match(/Client ID:\s+(\S+)/) ?? ['', ''])[1];
|
||||
oauthClientSecret = (regOut.match(/Client Secret:\s+(\S+)/) ?? ['', ''])[1];
|
||||
if (!oauthClientId || !oauthClientSecret) throw new Error(`register-client did not yield creds:\n${regOut}`);
|
||||
|
||||
server = spawn('bun', [
|
||||
'run', 'src/cli.ts', 'serve', '--http',
|
||||
'--bind', '127.0.0.1', '--port', String(PORT),
|
||||
'--public-url', BASE,
|
||||
], { cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let serr = '';
|
||||
server.stderr?.on('data', (d: Buffer) => { serr += d.toString(); });
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
if (res.ok) { serverReady = true; break; }
|
||||
} catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
if (!serverReady) throw new Error(`serve --http did not become ready:\n${serr}`);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (server) { try { server.kill('SIGTERM'); } catch { /* best-effort */ } }
|
||||
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
||||
});
|
||||
|
||||
test('real bearer token round-trips get_brain_identity', async () => {
|
||||
expect(serverReady).toBe(true);
|
||||
const r = await probeBrainIdentity(MCP_URL, token, { timeoutMs: 15_000 });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
// get_brain_identity returns the version/engine counter packet.
|
||||
expect(r.identity).toMatch(/version/);
|
||||
expect(r.identity).toMatch(/pglite/);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('wrong token classifies as auth', async () => {
|
||||
expect(serverReady).toBe(true);
|
||||
const r = await probeBrainIdentity(MCP_URL, 'gbrain_deadbeef', { timeoutMs: 15_000 });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe('auth');
|
||||
}, 30_000);
|
||||
|
||||
test('unreachable host classifies as unreachable or timeout', async () => {
|
||||
// 127.0.0.1:1 is reserved/closed — connection refused or fast timeout.
|
||||
const r = await probeBrainIdentity('http://127.0.0.1:1/mcp', token, { timeoutMs: 4_000 });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(['unreachable', 'timeout']).toContain(r.reason);
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Real-CLI coverage: drive the actual `claude` / `codex` binaries through
|
||||
// `gbrain connect --install` against the live server. Sandboxed via HOME /
|
||||
// CODEX_HOME so the dev machine's real agent config is untouched. Skips
|
||||
// gracefully when a binary isn't on PATH (e.g. CI without the CLIs).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const hasBin = (b: string): boolean => {
|
||||
try { execFileSync(b, ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }
|
||||
};
|
||||
const HAS_CLAUDE = hasBin('claude');
|
||||
const HAS_CODEX = hasBin('codex');
|
||||
|
||||
// Run `gbrain connect <args>` as a subprocess with extra env (HOME/CODEX_HOME
|
||||
// sandbox + GBRAIN_REMOTE_TOKEN). spawnSync captures stderr too — connect's
|
||||
// "Verified" / "Added" lines go to stderr.
|
||||
const runConnectCli = (args: string[], extraEnv: Record<string, string>): { code: number; out: string } => {
|
||||
const r = spawnSync('bun', ['run', 'src/cli.ts', 'connect', ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GBRAIN_HOME: home, ...extraEnv },
|
||||
});
|
||||
return { code: r.status ?? 1, out: `${r.stdout ?? ''}\n${r.stderr ?? ''}` };
|
||||
};
|
||||
|
||||
(HAS_CLAUDE ? test : test.skip)('claude-code --install registers + connects against the live server', () => {
|
||||
expect(serverReady).toBe(true);
|
||||
const claudeHome = mkdtempSync(join(tmpdir(), 'gb-claude-'));
|
||||
try {
|
||||
const r = runConnectCli([MCP_URL, '--token', token, '--install', '--yes'], { HOME: claudeHome });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/Verified/);
|
||||
// The real `claude` CLI actually registered the server.
|
||||
const got = spawnSync('claude', ['mcp', 'get', 'gbrain'], { encoding: 'utf8', env: { ...process.env, HOME: claudeHome } });
|
||||
expect(got.status).toBe(0);
|
||||
expect(`${got.stdout ?? ''}${got.stderr ?? ''}`).toContain(`:${PORT}/mcp`);
|
||||
} finally {
|
||||
try { spawnSync('claude', ['mcp', 'remove', 'gbrain'], { env: { ...process.env, HOME: claudeHome } }); } catch { /* best-effort */ }
|
||||
rmSync(claudeHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
(HAS_CODEX ? test : test.skip)('codex --install registers the env-var bearer against the live server', () => {
|
||||
expect(serverReady).toBe(true);
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'gb-codex-'));
|
||||
try {
|
||||
const r = runConnectCli([MCP_URL, '--token', token, '--agent', 'codex', '--install', '--yes'], { CODEX_HOME: codexHome, GBRAIN_REMOTE_TOKEN: token });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/Verified/);
|
||||
// The real `codex` CLI registered the streamable-http server with the
|
||||
// env-var bearer — and the token never lands in Codex config.
|
||||
const got = spawnSync('codex', ['mcp', 'get', 'gbrain'], { encoding: 'utf8', env: { ...process.env, CODEX_HOME: codexHome } });
|
||||
expect(got.status).toBe(0);
|
||||
const text = `${got.stdout ?? ''}${got.stderr ?? ''}`;
|
||||
expect(text).toContain('GBRAIN_REMOTE_TOKEN');
|
||||
expect(text).not.toContain(token);
|
||||
expect(text).toContain(`:${PORT}/mcp`);
|
||||
} finally {
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// Perplexity Computer is a GUI connector (Settings → Connectors, Pro account):
|
||||
// there is no CLI to wire E2E. We can only assert `connect` prints the exact
|
||||
// values the user pastes into the GUI.
|
||||
test('perplexity print mode yields the GUI connector values (no CLI to wire E2E)', () => {
|
||||
expect(serverReady).toBe(true);
|
||||
const r = runConnectCli([MCP_URL, '--token', token, '--agent', 'perplexity'], {});
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toContain(`:${PORT}/mcp`);
|
||||
expect(r.out).toContain(token); // print mode shows the token to paste into the connector
|
||||
expect(r.out).toMatch(/Settings.+Connectors/);
|
||||
}, 30_000);
|
||||
|
||||
// The OAuth path Perplexity actually uses, proven end-to-end against the live
|
||||
// server: register a client → connect --oauth formats it → mint a real
|
||||
// client-credentials access token via OAuth discovery + /token → call
|
||||
// get_brain_identity with that token. This exercises the whole chain a
|
||||
// Perplexity OAuth connector walks.
|
||||
test('perplexity OAuth: connect --oauth → mint client-credentials token → tool call', async () => {
|
||||
expect(serverReady).toBe(true);
|
||||
// The OAuth client was registered in beforeAll (before serve took the
|
||||
// PGLite write lock). Here: format it, then walk the connector's flow.
|
||||
// 1. `connect --oauth` formats the connector block with the right issuer.
|
||||
const conn = runConnectCli([MCP_URL, '--agent', 'perplexity', '--oauth', '--client-id', oauthClientId, '--client-secret', oauthClientSecret], {});
|
||||
expect(conn.code).toBe(0);
|
||||
expect(conn.out).toContain(`Issuer URL: ${BASE}`);
|
||||
expect(conn.out).toContain(`Client ID: ${oauthClientId}`);
|
||||
|
||||
// 2. Mint a real access token the way a connector does, then call a tool.
|
||||
const disco = await discoverOAuth(BASE, { timeoutMs: 10_000 });
|
||||
expect(disco.ok).toBe(true);
|
||||
if (!disco.ok) return;
|
||||
const minted = await mintClientCredentialsToken(disco.metadata.token_endpoint, oauthClientId, oauthClientSecret, { scope: 'read write', timeoutMs: 10_000 });
|
||||
expect(minted.ok).toBe(true);
|
||||
if (!minted.ok) return;
|
||||
const probed = await probeBrainIdentity(MCP_URL, minted.token.access_token, { timeoutMs: 15_000 });
|
||||
expect(probed.ok).toBe(true);
|
||||
if (probed.ok) expect(probed.identity).toMatch(/version/);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* E2E for the "standalone from nothing" funnel: `gbrain init --pglite` →
|
||||
* `gbrain serve` (stdio) wired into a coding agent as an MCP subprocess.
|
||||
*
|
||||
* This is the canonical local path the docs encourage for Claude Code / Codex
|
||||
* users with no remote brain:
|
||||
*
|
||||
* claude mcp add gbrain -- gbrain serve
|
||||
* codex mcp add gbrain -- gbrain serve
|
||||
*
|
||||
* The `connect`/bearer E2E proves the REMOTE (HTTP) funnel. Nothing proved the
|
||||
* LOCAL stdio funnel end-to-end: that a freshly-init'd PGLite brain, served
|
||||
* over stdio, actually answers real MCP `tools/call`s through the official MCP
|
||||
* SDK client (the same handshake Claude Code / Codex perform). The
|
||||
* serve-stdio-lifecycle unit test only covers shutdown signalling.
|
||||
*
|
||||
* No Postgres / Docker. PGLite, hermetic temp HOME. Drives the real
|
||||
* StdioClientTransport, so the MCP SDK spawns `gbrain serve` for us, runs the
|
||||
* `initialize` handshake, and round-trips `tools/list` + `tools/call`.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
|
||||
// Distinctive token so keyword search can't accidentally match anything else.
|
||||
const MARKER = 'qantani-marker-9f3z';
|
||||
|
||||
function textOf(result: unknown): string {
|
||||
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n');
|
||||
}
|
||||
|
||||
describe('serve stdio round-trip E2E (local PGLite → real MCP tool calls)', () => {
|
||||
let home: string;
|
||||
let client: Client | null = null;
|
||||
let transport: StdioClientTransport | null = null;
|
||||
let connected = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-stdio-e2e-'));
|
||||
const env = { ...process.env, GBRAIN_HOME: home };
|
||||
|
||||
// 1. Init a local PGLite brain (the "from nothing" step).
|
||||
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
|
||||
cwd: process.cwd(), env, stdio: 'ignore',
|
||||
});
|
||||
|
||||
// 2. Seed one page so page_count > 0 and search has something to find.
|
||||
// --no-embed keeps it hermetic (no embedding provider configured); the
|
||||
// keyword path still finds the distinctive marker.
|
||||
const notes = join(home, 'notes');
|
||||
mkdirSync(notes, { recursive: true });
|
||||
writeFileSync(
|
||||
join(notes, 'marker.md'),
|
||||
`---\ntitle: ${MARKER} note\n---\n\n# ${MARKER}\n\nThis page exists to prove ${MARKER} is retrievable over stdio MCP.\n`,
|
||||
);
|
||||
execFileSync('bun', ['run', 'src/cli.ts', 'import', notes, '--no-embed'], {
|
||||
cwd: process.cwd(), env, stdio: 'ignore',
|
||||
});
|
||||
|
||||
// 3. Let the MCP SDK spawn `gbrain serve` (stdio) and run the initialize
|
||||
// handshake — exactly what `claude mcp add gbrain -- gbrain serve` does.
|
||||
transport = new StdioClientTransport({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/cli.ts', 'serve'],
|
||||
cwd: process.cwd(),
|
||||
env, // includes PATH (to find `bun`) + GBRAIN_HOME
|
||||
});
|
||||
client = new Client({ name: 'gbrain-stdio-e2e', version: '1.0.0' }, { capabilities: {} });
|
||||
await client.connect(transport);
|
||||
connected = true;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) { try { await client.close(); } catch { /* best-effort */ } }
|
||||
if (transport) { try { await transport.close(); } catch { /* best-effort */ } }
|
||||
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
|
||||
});
|
||||
|
||||
test('initialize handshake + tools/list exposes the core retrieval tools', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const { tools } = await client!.listTools();
|
||||
const names = new Set(tools.map((t) => t.name));
|
||||
// The core MCP tools the connect LEARN_INSTRUCTION promises always work.
|
||||
// `capture` is deliberately NOT here — it's a CLI-only wrapper, not an MCP
|
||||
// tool; the agent writes via put_page (regression guard for the stale
|
||||
// LEARN_INSTRUCTION that named capture as an MCP tool).
|
||||
for (const core of ['search', 'query', 'get_page', 'put_page', 'get_brain_identity', 'think', 'find_experts']) {
|
||||
expect(names.has(core)).toBe(true);
|
||||
}
|
||||
expect(names.has('capture')).toBe(false); // CLI-only, must not be advertised as MCP
|
||||
}, 30_000);
|
||||
|
||||
test('tools/call get_brain_identity returns version + engine + a populated counter', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const res = await client!.callTool({ name: 'get_brain_identity', arguments: {} });
|
||||
const text = textOf(res);
|
||||
const id = JSON.parse(text) as { version: string; engine: string; page_count: number };
|
||||
expect(typeof id.version).toBe('string');
|
||||
expect(id.engine).toBe('pglite');
|
||||
expect(id.page_count).toBeGreaterThanOrEqual(1); // the seeded page
|
||||
}, 30_000);
|
||||
|
||||
test('tools/call search surfaces the seeded page (keyword path, no embeddings)', async () => {
|
||||
expect(connected).toBe(true);
|
||||
const res = await client!.callTool({ name: 'search', arguments: { query: MARKER, limit: 5 } });
|
||||
const text = textOf(res);
|
||||
// The result payload (slug / title / snippet) must mention the marker.
|
||||
expect(text).toContain(MARKER);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unit coverage for the `gbrain serve --http` skill-publishing banner + nudge
|
||||
* (`skillPublishStatus`). When skill publishing is OFF, a connected coding
|
||||
* agent (Codex / Claude Code / Perplexity) can't call list_skills / get_skill,
|
||||
* so the host's skill catalog is invisible to it. The operator should learn
|
||||
* this at serve startup, not from an empty list on the agent side — which is
|
||||
* the exact friction this nudge closes for the "add my coding agent to my
|
||||
* existing brain" funnel.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { skillPublishStatus } from '../src/commands/serve-http.ts';
|
||||
|
||||
describe('skillPublishStatus', () => {
|
||||
test('publishing ON: banner says published, no nudge', () => {
|
||||
const s = skillPublishStatus(true);
|
||||
expect(s.bannerValue).toBe('published');
|
||||
expect(s.nudge).toBeNull();
|
||||
});
|
||||
|
||||
test('publishing OFF: banner says not published', () => {
|
||||
const s = skillPublishStatus(false);
|
||||
expect(s.bannerValue).toBe('not published');
|
||||
expect(s.nudge).not.toBeNull();
|
||||
});
|
||||
|
||||
test('OFF nudge carries the paste-ready fix command', () => {
|
||||
const s = skillPublishStatus(false);
|
||||
expect(s.nudge).toContain('gbrain config set mcp.publish_skills true');
|
||||
});
|
||||
|
||||
test('OFF nudge names the affected tools so the operator understands the blast radius', () => {
|
||||
const s = skillPublishStatus(false);
|
||||
expect(s.nudge).toContain('list_skills');
|
||||
expect(s.nudge).toContain('get_skill');
|
||||
});
|
||||
|
||||
test('OFF nudge reassures that core tools still work (so operators do not over-react)', () => {
|
||||
const s = skillPublishStatus(false);
|
||||
expect(s.nudge!.toLowerCase()).toContain('core tools');
|
||||
});
|
||||
|
||||
test('banner value fits the fixed-width startup box (≤ 40 chars after padEnd)', () => {
|
||||
// The banner pads each value with .padEnd(40); a longer raw value would
|
||||
// blow out the ASCII box. Guard the contract here.
|
||||
expect(skillPublishStatus(true).bannerValue.length).toBeLessThanOrEqual(40);
|
||||
expect(skillPublishStatus(false).bannerValue.length).toBeLessThanOrEqual(40);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user