mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
* 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>
862 lines
37 KiB
TypeScript
862 lines
37 KiB
TypeScript
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/);
|
|
});
|
|
});
|