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:
Garry Tan
2026-06-01 19:09:15 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent eefe8b5741
commit 7b0d99adb0
25 changed files with 2966 additions and 47 deletions
+11 -5
View File
@@ -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 });
});
});
});
+38
View File
@@ -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']);
});
});
+861
View File
@@ -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/);
});
});
+207
View File
@@ -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);
});
+117
View File
@@ -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);
});
+48
View File
@@ -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);
});
});