diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc75a0b7..e98d11aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,156 @@ All notable changes to GBrain will be documented in this file. +## [0.42.2.0] - 2026-05-30 + +**One command now wires Claude Code, Codex, or Perplexity Computer to a remote +gbrain when all you have is a bearer token. `gbrain connect --token ` +prints a paste-ready block, or `--install` runs it for you and checks the token +actually works before you walk away.** + +If your brain runs somewhere as an HTTP server (`gbrain serve --http`) and you +have a token, connecting an agent used to mean remembering the exact +`claude mcp add ... -H "Authorization: Bearer ..."` incantation, getting the +`/mcp` path right, and hoping the token was valid. Now you run one command. +It normalizes the URL (adds `/mcp`, rejects a bare host so you don't silently +point at the wrong thing), and the block it prints tells the agent to call +`get_brain_identity` and `list_skills` so it immediately knows whose brain this +is and everything it can do. No local brain, no proxy, no OAuth dance: the agent +talks straight to your remote over HTTP. + +Pick your agent with `--agent`: + +- **claude-code** (default): `claude mcp add ... -H "Authorization: Bearer ..."`. + `--install` runs it. +- **codex**: `codex mcp add --url --bearer-token-env-var + GBRAIN_REMOTE_TOKEN`. Codex reads the token from the env var at runtime, so the + secret never lands in Codex's config file. `--install` runs it. +- **perplexity**: prints the exact connector fields to paste into Perplexity's + Settings → Connectors (it's a GUI connector, so no `--install`). Defaults to a + bearer token, but since Perplexity is a cloud service the recommended path is + **OAuth**: `--agent perplexity --oauth --register` mints a least-privilege + client and prints the Issuer URL + Client ID + Client Secret. OAuth means the + connector mints short-lived, scoped access tokens instead of holding a + long-lived full-access secret. +- **generic**: prints the URL + `Authorization` header (or OAuth fields with + `--oauth`) for any other MCP client. + +How to use it (run anywhere gbrain is installed): + +``` +gbrain auth create "claude-code" # mint a token on the host +gbrain connect https://your-host/mcp --token gbrain_xxx # print the paste block +gbrain connect https://your-host --token gbrain_xxx --install # or wire it + verify the token +``` + +`--install` runs `claude mcp add` for you, then makes one real call to your brain +so a wrong or expired token fails right then instead of silently 401-ing on the +agent's first question. `--json` gives you a machine-readable version with the +token redacted (pass `--show-token` if you really want it inlined). + +A note on the token: a `gbrain auth create` token is long-lived and full-access. +The printed block single-quotes it so pasting it can't accidentally run shell +code, the command refuses to send it to a link-local or cloud-metadata address, +and error output never echoes it. Keep it private, and prefer a scoped token if +your host supports one. + +**Two ways to give a coding agent a memory, written down end to end.** Connecting +to a remote brain is one funnel. The other is starting from nothing: `gbrain init +--pglite` gives you a local brain in 2 seconds, and `claude mcp add gbrain -- +gbrain serve` (or `codex mcp add gbrain -- gbrain serve`) wires it straight into +your agent with no server, no token, no tunnel. The new tutorial, +[Give your coding agent a memory](docs/tutorials/connect-coding-agent.md), walks +both funnels with copy-paste commands, then hands you the brain-first protocol to +paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it worth it +(brain-first lookup, ambient capture, briefing from your brain, whoknows). The +README now has a "Quick start: Claude Code or Codex" fork that separates the +lightweight retrieval path from the full autonomous install, and `INSTALL.md` +shows the one-command wire-up right where the standalone CLI section ends. + +**`gbrain serve --http` now tells you when your skills are invisible.** If +`mcp.publish_skills` is OFF, a connected agent can search and write but can't call +`list_skills` / `get_skill` — so your skill catalog (the thing that makes an +OpenClaw setup special) silently doesn't show up. The startup banner now prints a +`Skills: published / not published` line, and when it's off you get a one-line +nudge with the exact fix: `gbrain config set mcp.publish_skills true`. New brains +from `gbrain init` default it ON; brains upgraded from before stay OFF until you +opt in, which is the common gotcha. + +**Fixed: `connect` told agents to call a tool that doesn't exist over MCP.** The +self-orientation block named `capture` as a core tool, but `capture` is a CLI-only +convenience command, not an MCP tool — an agent that followed the instruction got +"unknown tool." The block now names `put_page`, the real MCP write tool. A new +end-to-end test spawns `gbrain serve` over stdio and drives the official MCP SDK +client through `initialize` → `tools/list` → `tools/call`, so the advertised tool +set is now pinned against what the server actually exposes (the local stdio funnel +had zero coverage before this). + +## To take advantage of v0.42.2.0 + +`gbrain upgrade` is all you need. `gbrain connect` is available immediately after +upgrade. To wire up a coding agent: + +1. On the brain host (or anywhere gbrain is installed), mint a token: + ```bash + gbrain auth create "claude-code" + ``` +2. Generate the onboarding block (or wire it directly): + ```bash + gbrain connect https://your-host/mcp --token + # or, on the machine you want to connect: + gbrain connect https://your-host/mcp --token --install + ``` +3. Paste the printed block into Claude Code. It connects the MCP server and + tells the agent to call `get_brain_identity` + `list_skills`. +4. Verify: in Claude Code, ask it to `search` for something in your brain. + +If anything looks wrong, `gbrain connect --help` lists every flag, and +`docs/mcp/CLAUDE_CODE.md` covers the local-stdio path too. + +### Itemized changes + +#### Added +- **`gbrain connect `** generates (or, with `--install`, runs) the MCP + wiring for a remote gbrain from a bearer token. Flags: `--token`, `--name`, + `--agent claude-code|codex|perplexity|generic`, `--install`, `--yes`, `--force`, + `--json`, `--show-token`, `--timeout-ms`. Reads the token from `--token` or + `$GBRAIN_REMOTE_TOKEN`; in print mode the token is optional (it emits a + `` placeholder). +- **Per-agent setup**: `--agent codex` emits `codex mcp add ... --bearer-token-env-var + GBRAIN_REMOTE_TOKEN` (token read from the env var at runtime, never written to + Codex config; `--install` runs it). `--agent perplexity` prints the URL + token + for Perplexity's Settings → Connectors GUI (no `--install`). `--agent generic` + prints the URL + `Authorization` header for any other MCP client. Docs: + `docs/mcp/CLAUDE_CODE.md` (leads with `gbrain connect`, keeps the local stdio + path), new `docs/mcp/CODEX.md`, updated `docs/mcp/PERPLEXITY.md`, and the README. +- **`--install` smoke-tests the token.** After registering the server it makes a + real `get_brain_identity` call over the bearer connection and warns loudly on + a 401, unreachable host, or timeout, so a bad token fails at setup instead of + on the agent's first request. Supported for claude-code and codex (Perplexity + is GUI-only). +- **OAuth client-credentials path (`--oauth`, perplexity + generic).** The + correct path when the credential lives on a third-party cloud: instead of a + long-lived full-access bearer token, the connector gets an Issuer URL + Client + ID + Client Secret and mints its own short-lived, scoped access tokens. + `--oauth --register` mints a least-privilege client on the host in one command; + `--oauth --client-id X --client-secret Y` uses an existing one (runs anywhere). + The full chain (register → OAuth discovery → `/token` → tool call) is proven by + a new end-to-end test against a live server. + +#### Fixed +- **`gbrain auth create ` no longer drops the name.** On the bare form + (no `--takes-holders` flag) the name was silently discarded and the command + printed usage instead of minting a token. It now creates the token as + documented. + +#### Security +- The connection command single-quotes the rendered `claude mcp add` so a token + containing shell metacharacters can't run code when the block is pasted; + validates the token to keep it out of HTTP headers; refuses to send the token + to link-local / cloud-metadata addresses (including IPv4-mapped IPv6 forms); + redacts the token from all error output and from `--json` unless `--show-token`; + and requires `--yes` for `--install` in a non-interactive shell. + ## [0.42.1.0] - 2026-05-29 **Skill self-improvement no longer starts from a blank file.** diff --git a/CLAUDE.md b/CLAUDE.md index d964afb89..412edb2b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,7 +258,8 @@ strict behavior when unset. - `src/commands/serve.ts` (v0.31.3) — `gbrain serve` stdio MCP entrypoint with idempotent shutdown across every parent-disconnect signal. Stdio EOF, SIGTERM, SIGINT, SIGHUP, and parent-process death (every reparent case — PID 1, launchd subreaper, systemd, tmux, or a parent shell with `PR_SET_CHILD_SUBREAPER`) all funnel into one `cleanup(reason)` path that releases the engine and the PGLite write-lock dir within 5 seconds. Pre-v0.31.3 the stdio MCP server held the lock indefinitely after Claude Desktop / Cursor / launchd-managed gateways disconnected, forcing a 5-minute stale-lock wait on the next start. Watchdog reparent check is `getParentPid() !== initialParentPid` (capturing the initial ppid once at install time and firing on any change); the previous `=== 1` check missed the subreaper case under launchd / systemd. Bun's `process.ppid` cache is stale across reparenting (see [oven-sh/bun#30305](https://github.com/oven-sh/bun/issues/30305)) so `getParentPid()` runs `spawnSync('ps', ['-o', 'ppid=', '-p', PID])` per tick to read the live kernel PPID. Startup probe verifies `ps` is on PATH; if not (stripped containers, busybox without procps), the watchdog skips installing AND emits a loud `[gbrain serve] watchdog disabled: ps unavailable, parent-death detection unavailable — child will rely on stdin EOF / signals only` stderr line so operators see the degraded mode at boot. Pinned by `test/serve-stdio-lifecycle.test.ts` (22 cases). Closes #413, #446. Credit @Aragorn2046 (origin features in #591) and @seungsu-kr (rebased submitter, Bun ppid workaround). - `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. **v0.26.9 RFC 6749/7009 hardening pass:** F1+F2 fold `client_id` atomically into the `DELETE WHERE` clauses for both auth-code exchange and refresh rotation — pre-fix the post-hoc client compare burned the row on wrong-client paths so the legitimate client couldn't retry. F3 enforces refresh-scope-subset against the original grant on the row (RFC 6749 §6), not the client's currently-allowed scopes — fixes the case where revoking a scope from a client wouldn't shrink the agent's existing refresh tokens. F4 binds `client_id` on `revokeToken` so a client can only revoke its own tokens (RFC 7009 §2.1). F7c validates the `/token` request's `redirect_uri` against the value stored at `/authorize` (RFC 6749 §4.1.3) — empty-string treated as missing rather than wildcard match (adversarial-review fix). F5 swaps bare `catch {}` blocks in `verifyAccessToken` and `getClient` for `isUndefinedColumnError` from `src/core/utils.ts` — only SQLSTATE 42703 falls through to legacy fallback; lock timeouts and network blips throw and surface. F6 makes `sweepExpiredTokens()` actually return the count via `RETURNING 1` + array length, not a fire-and-forget zero. F12 adds `dcrDisabled` constructor option so `serve-http.ts` can disable the `/register` endpoint without monkey-patching the router. **v0.26.2:** module-private `coerceTimestamp()` boundary helper at the top of the file normalizes postgres-driver-as-string BIGINT columns to JS numbers at every read site (5 call sites: `getClient` L112+L113 for DCR `/register` RFC 7591 §3.2.1 numeric timestamps, `exchangeRefreshToken` L274 + `verifyAccessToken` L296+L303 for the SDK's `typeof === 'number'` bearerAuth check). Throws on non-finite input (NaN/Infinity) so corrupt rows fail loud at the boundary instead of riding through as `expiresAt: NaN`; returns undefined for SQL NULL so callers decide NULL semantics explicitly (refresh + access token paths treat NULL as expired). Helper intentionally NOT promoted to `src/core/utils.ts` — codex review flagged repo-wide BIGINT precision-loss risk for a generic helper. **v0.34.1.0 (#909):** `registerClient` honors `token_endpoint_auth_method: "none"` (RFC 7591 §3.2.1) — public PKCE clients (Claude Code, Cursor, every other PKCE-first MCP client) store `client_secret_hash = NULL` and the response payload omits `client_secret` entirely. Confidential clients (default `client_secret_post` and explicit `client_secret_basic`) keep their one-time-reveal shape. `getClient` correctly normalizes a NULL `client_secret_hash` to JS `undefined` so the SDK's clientAuth path accepts the public client at `/token`. **v0.34.1.0 (#861 + #876):** `verifyAccessToken` JOINs `oauth_clients.source_id` (write scope, scalar) + `oauth_clients.federated_read` (read scope, TEXT[]) and surfaces both on the returned `AuthInfo`. Pre-v60 / pre-v61 brains degrade gracefully via `isUndefinedColumnError` fallback so the upgrade chain is non-blocking on legacy DBs. - `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries. -- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. **v0.31.3 (#681):** every SQL site routes through `sqlQueryForEngine(engine)` from `src/core/sql-query.ts` (and `executeRawJsonb` for the takes-holders `permissions` JSONB column) so `gbrain auth` works against PGLite brains. Pre-fix, every call hit the postgres.js singleton via `getConn()` and silently failed (or wrote to the wrong DB) when the active engine was PGLite. The takes-holders write goes through `executeRawJsonb(engine, sql, [name, hash], [{takes_holders:[...]}])` which round-trips with `jsonb_typeof = 'object'` instead of the pre-v0.31.3 quoted-string shape. **v0.34.1.0 (#876):** `register-client` accepts `--source ` (write authority, scalar) and `--federated-read ` (read scope, array). The output prints the resolved `Write source` and `Federated reads` for the registered client. Pre-v0.34 clients backfill to `source_id='default'` via migration v60 so existing deployments keep their v0.33 effective behavior verbatim. +- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. **v0.31.3 (#681):** every SQL site routes through `sqlQueryForEngine(engine)` from `src/core/sql-query.ts` (and `executeRawJsonb` for the takes-holders `permissions` JSONB column) so `gbrain auth` works against PGLite brains. Pre-fix, every call hit the postgres.js singleton via `getConn()` and silently failed (or wrote to the wrong DB) when the active engine was PGLite. The takes-holders write goes through `executeRawJsonb(engine, sql, [name, hash], [{takes_holders:[...]}])` which round-trips with `jsonb_typeof = 'object'` instead of the pre-v0.31.3 quoted-string shape. **v0.34.1.0 (#876):** `register-client` accepts `--source ` (write authority, scalar) and `--federated-read ` (read scope, array). The output prints the resolved `Write source` and `Federated reads` for the registered client. Pre-v0.34 clients backfill to `source_id='default'` via migration v60 so existing deployments keep their v0.33 effective behavior verbatim. **v0.42.2.0:** the bare `gbrain auth create ` form (no `--takes-holders` flag) was silently dropping the name and printing usage instead of minting a token. The positional-vs-flag parse is extracted into the exported pure `parseAuthCreateArgs(rest)` so it's unit-testable — the pre-fix inline version used `rest[takesIdx + 1]` which resolved to `rest[0]` (the name) when `takesIdx === -1`, excluding the name from the positional search. Pinned by `test/auth-create-args.test.ts`. +- `src/commands/connect.ts` + `src/core/connect-probe.ts` (v0.42.2.0) — `gbrain connect [--token ]` one-command coding-agent onboarding from a bearer token. Turns an MCP URL + token into a paste-ready `claude mcp add ... -H "Authorization: Bearer ..."` block (default) or, with `--install`, runs it directly and smoke-tests the token. Direct HTTP MCP — Claude Code talks straight to a remote `gbrain serve --http`; no local install or thin-client OAuth config needed for the connection itself (the local CLI over a bearer token is deferred T7 in TODOS). Token resolution: `--token` > `$GBRAIN_REMOTE_TOKEN` > (print mode) a `` placeholder / (install mode) error. The generated block tells the agent to call `get_brain_identity` + `list_skills` (the `LEARN_INSTRUCTION` export) so it self-orients on what the brain is and can do, with a core-tools fallback list for hosts that haven't enabled skill publishing. URL normalization appends `/mcp` to a bare host but REJECTS a scheme-less host (so you can't silently point at the wrong thing); pure helpers (`isLinkLocalOrMetadata`, URL parse, render) are unit-tested. Flags: `--token`, `--name ` (default `gbrain`, validated against `NAME_RE`), `--agent claude-code|codex|perplexity|generic`, `--install`, `--yes` (required for `--install` in a non-TTY), `--force` (replace an existing same-name server), `--json` (token redacted unless `--show-token`), `--timeout-ms`. `connect` is in `CLI_ONLY` + `CLI_ONLY_SELF_HELP` (prints its own `--help`); dispatched in `cli.ts:handleCliOnly` with no local DB connect (print mode touches nothing; `--install` talks to the remote). **Multi-agent (v0.42.2.0):** `AGENT_SPECS` drives per-agent rendering + `--install` dispatch. `claude-code` → `buildClaudeMcpAddArgv` (literal `-H "Authorization: Bearer "`). `codex` → `buildCodexMcpAddArgv` = `codex mcp add --url --bearer-token-env-var GBRAIN_REMOTE_TOKEN` (Codex reads the token from the env var at runtime — never written to its config; `--install` runs `codex mcp add` and prints an `export GBRAIN_REMOTE_TOKEN` hint when the live env doesn't already carry it). `perplexity` + `generic` are `installable:false` (perplexity prints Settings → Connectors GUI steps; generic prints URL + header) and reject `--install`. **OAuth (`--oauth`, `supportsOAuth:true` agents = perplexity/generic only):** emits an OAuth 2.1 client-credentials connector block (Issuer URL via `issuerFromMcpUrl` = mcp-url minus `/mcp`, Client ID, Client Secret) instead of a bearer header — the correct path for a third-party cloud connector (least-privilege scopes + short-lived rotating tokens vs a long-lived full-access secret). Creds from `--client-id`/`--client-secret` (BYO, runs anywhere) or `--register` (host-side: `deps.registerOAuthClient` shells `gbrain auth register-client --grant-types client_credentials --scopes --token-endpoint-auth-method client_secret_post` and parses `Client ID:`/`Client Secret:`). `--oauth` is rejected for claude-code/codex and is incompatible with `--install`. `buildJson` oauth shape: `auth:'oauth'`, `issuer_url`, `client_id`, `client_secret` (redacted unless `--show-token`), `scopes`. Full OAuth chain (register → connect --oauth → OAuth discovery → `/token` client_credentials mint → `get_brain_identity`) is E2E-proven in `connect-bearer.test.ts` (client registered in `beforeAll` before serve takes the PGLite single-writer lock). `cmdString(binary, argv)` POSIX-single-quotes args (the codex `export ` line is single-quoted too). `buildJson` is a generic shape (`agent`, `command`/`command_argv` null for perplexity/generic, `header`, `env_var`); the codex `command` carries only the env-var name, never the token. `ConnectDeps` = `{isTTY, promptYesNo, hasBinary(bin), runBinary(bin, argv), probe, env(name)}` — binary-generic so `claude` and `codex` share the path; `env` injectable so tests control `GBRAIN_REMOTE_TOKEN`. Per-agent docs: `docs/mcp/CODEX.md` (new), `docs/mcp/PERPLEXITY.md` (connect fast-path added). Security posture: the rendered command single-quotes the token so shell metacharacters in a token can't run code when pasted; the token is validated before it lands in an HTTP header; link-local / cloud-metadata addresses (incl. IPv4-mapped IPv6 `::ffff:169.254.x.x` and AWS IMDSv2-over-IPv6 `fd00:ec2::254`) are refused as a token-exfil guard while localhost / RFC1918 / LAN stay allowed (self-hosted is a supported topology); the token is redacted from all error output. `src/core/connect-probe.ts` is the raw-bearer MCP smoke probe backing `--install`: connects the official MCP SDK `Client` over `StreamableHTTPClientTransport` with a STATIC `Authorization` header (no OAuth, no discovery — distinct from `mcp-client.ts:callRemoteTool` which is OAuth-only, and from `remote-mcp-probe.ts:smokeTestMcp` which only sends `initialize`), runs the full `initialize` handshake via `client.connect()`, then calls `get_brain_identity` (read-scope, non-localOnly) to prove a tool call actually round-trips. Never throws — every failure maps to a discriminated `{ ok: false, reason: 'auth' | 'unreachable' | 'timeout' | 'tool_error' | 'unknown', message }` so the caller renders a precise warning (a wrong/expired token fails at setup, not silently on the agent's first request). `DEFAULT_PROBE_TIMEOUT_MS = 15_000` is the single source of truth shared with `connect.ts`. Pinned by `test/connect.test.ts` (pure-helper + render coverage, all four agents) + `test/e2e/connect-bearer.test.ts`: the raw-bearer probe against a live `gbrain serve --http` AND real-CLI coverage that drives the actual `claude` + `codex` binaries through `connect --install` against the live server (sandboxed `HOME` / `CODEX_HOME`, asserts `claude mcp get` / `codex mcp get` registered it + the token never lands in Codex config; skips when a binary is absent, e.g. CI). Perplexity is a GUI connector so it's print-asserted only (no CLI to wire E2E). Surfaced in `docs/mcp/CLAUDE_CODE.md`, `docs/mcp/CODEX.md`, `docs/mcp/PERPLEXITY.md`, and the README. **v0.42.2.0 follow-up (two-funnel onboarding):** `LEARN_INSTRUCTION` no longer names `capture` — it's a CLI-only command, NOT an MCP tool, so an agent that followed the block hit "unknown tool"; the list now names `put_page` (the real MCP write tool), pinned by a `connect.test.ts` regression block AND by the new `test/e2e/serve-stdio-roundtrip.test.ts` which spawns real `gbrain serve` (stdio) against a fresh `init --pglite` brain and drives the official MCP SDK client through `initialize` → `tools/list` → `tools/call` (`get_brain_identity` + `search`), asserting the advertised core-tool set matches what the server exposes (and that `capture` is NOT advertised) — the local stdio funnel's first e2e coverage. `src/commands/serve-http.ts` adds an exported pure `skillPublishStatus(publishSkills)` consumed by the startup banner (`Skills: published / not published` line) + a one-line stderr nudge (`gbrain config set mcp.publish_skills true`) when publishing is OFF, so an operator wiring a coding agent to an existing brain sees their skill catalog is invisible to connected agents instead of discovering it via an empty `list_skills` (pinned by `test/serve-skills-publish-nudge.test.ts`). The user-facing walkthrough is `docs/tutorials/connect-coding-agent.md` (Path A: connect to an existing brain; Path B: start from nothing, local stdio) with the brain-first protocol + the four translatable habits; README has a "Quick start: Claude Code or Codex" fork and `INSTALL.md` shows the one-command wire-up at the standalone-CLI section. The pre-existing `test/audit/batch-retry-audit.test.ts` ENOENT case was made hermetic (it had read the real `~/.gbrain/audit`). - `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally. - `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry ` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed. **v0.41.37.0 (#1605):** new `in-process.ts` exports `runMigrateOnlyCore({timeoutMs?})` — the single source of truth for "bring schema to head" (`configureGateway` → `createEngine` → `connect` → `initSchema` → `disconnect`, idempotent, 600s wall-clock guard `MIGRATE_ONLY_TIMEOUT_MS`, throws `MigrateOnlyError` on no-config / timeout). The migration orchestrators' 9 schema phases AND `init.ts:initMigrateOnly` (the `gbrain init --migrate-only` CLI path) both delegate to it, so the schema bring-up can't drift between them. Previously each phase shelled out to a child `gbrain init --migrate-only` via `execSync`; on Windows + bun + Supabase pooler the SPAWNED child died with `getaddrinfo ENOTFOUND` before it could connect (a bun-on-Windows child-process DNS failure, NOT an env-propagation bug — the parent connects fine with `env: process.env`). Running in-process removes the spawn entirely. `runGbrainSubprocess` is the diagnostic wrapper for the REMAINING non-schema spawns (extract/repair/stats): captures child stderr (64MB buffer) and folds it into the thrown error so a Windows failure shows the real `getaddrinfo ENOTFOUND` line instead of a bare `Command failed`. Pinned by `test/migration-in-process.serial.test.ts`. **v0.41.37.0 (#1581):** `v0_13_1.ts:phaseCGrandfather` rewritten from a per-page `getPage` + `putPage` loop (which hung CPU-bound 70+ min on an 82K-page PGLite brain) to a CHUNKED bulk SQL pass. Three correctness properties the per-page loop and a naive single bulk UPDATE both got wrong: (1) keyed on `pages.id` (globally unique PK), NOT slug — slug uniqueness is `(source_id, slug)`, so a slug-batched UPDATE would mutate same-slug pages across other sources; (2) filters `deleted_at IS NULL` so tombstones aren't grandfathered; (3) chunked in `CHUNK_SIZE` batches (DELETE_BATCH_SIZE convention) so lock-hold stays bounded instead of one giant transaction. The rollback log carries source identity (`{id, slug, source_id, pre_frontmatter}`) so a rollback is unambiguous across sources. Idempotent + resumable (each UPDATE flips its rows out of `GRANDFATHER_WHERE`). Completes ~1-2s on the 82K-page brain. Pinned by `test/migrations-v0_13_1-grandfather.test.ts`. - `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent. diff --git a/README.md b/README.md index 7de84a4ea..91d66798a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GBrain -**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box. +**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box. Run a full autonomous agent on top of it, or just wire it into Claude Code or Codex as a supercharged retrieval layer in one command; either way your coding agent stops being amnesiac about everything that isn't code. I'm Garry Tan, President and CEO of Y Combinator. I built GBrain to run my own AI agents. It's the production brain behind my OpenClaw and Hermes deployments: **146,646 pages, 24,585 people, 5,339 companies**, 66 cron jobs running autonomously. My agent ingests meetings, emails, tweets, voice calls, and original ideas while I sleep. It enriches every person and company it encounters. It fixes its own citations and consolidates memory overnight. I wake up smarter than when I went to bed — and so will you. @@ -85,9 +85,29 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads 43 s > **Never set up an AI agent platform before?** The [personal-brain tutorial](docs/tutorials/personal-brain.md) walks the whole path end-to-end — picking OpenClaw vs Hermes, deploying it, pointing it at INSTALL_FOR_AGENTS.md, getting the API keys, and verifying the first query. Start there if any of the above is new. -### Install it into your existing agent +### Quick start: Claude Code or Codex -Already running Codex, Claude Code, Cursor, or another coding agent? Paste the same instruction in: +Already running Claude Code or Codex? There are two ways to wire GBrain in, depending on what you want. + +**Just want a memory for your coding agent (recommended starting point).** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel: + +```bash +gbrain init --pglite # 2-second local brain (no Docker) +claude mcp add gbrain -- gbrain serve # or: codex mcp add gbrain -- gbrain serve +``` + +**Already have a brain on a remote host** (OpenClaw, Hermes, or any `gbrain serve --http`)? Point your laptop agents at it with one command each — `--install` wires it up and smoke-tests the token before handoff: + +```bash +gbrain connect https://your-host/mcp --token gbrain_xxx --install # Claude Code +gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex +``` + +**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — both paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work. + +### Install the full autonomous setup into your existing agent + +Want the whole thing — local brain, 43 skills, the overnight dream cycle that enriches while you sleep? Paste this into Codex, Claude Code, Cursor, or another coding agent: ``` Retrieve and follow the instructions at: @@ -112,11 +132,12 @@ Postgres-at-scale, Supabase, and thin-client setup paths live in [`docs/INSTALL. GBrain exposes 30+ tools over MCP (stdio and HTTP). The specific snippet depends on which client you use: -- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — one command: `claude mcp add gbrain -- gbrain serve`. Zero server, zero tunnel. +- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token). +- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config. - **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config. - **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers. - **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors. -- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — Settings → Connectors → add the URL + bearer token. Pro subscription required. +- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required. - **[ChatGPT](docs/mcp/CHATGPT.md)** — uses OAuth 2.1 with PKCE (the hard requirement). Register a `chatgpt` client from the admin dashboard with grant type `authorization_code`. For the HTTP server itself: diff --git a/TODOS.md b/TODOS.md index 50eabd808..3c55df82a 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,27 @@ # TODOS +## v0.42.2.0 gbrain connect follow-ups (v0.42+) + +- [ ] **T6 (P3): `gbrain connect --env-token` form.** Ship the env-var-indirection + token form (`-H 'Authorization: Bearer ${GBRAIN_REMOTE_TOKEN}'`, single-quoted so + the shell doesn't pre-expand) ONLY after verifying that Claude Code actually expands + `${VAR}` inside a stored `-H` header at runtime. v0.42.2.0 deliberately ships the + literal-token default (matches the shipped docs, verified to work) because the + env-default was unverified — the shell expands `${...}` before `claude mcp add` + stores it, so it would have stored the literal token anyway. Verify CC behavior + first, then add the opt-in flag. Files: `src/commands/connect.ts` (token-form), + `docs/mcp/CLAUDE_CODE.md`. +- [ ] **T7 (P3): Tier 2 — local thin-client over a bearer token.** `gbrain connect` + today only wires the MCP *connection* (Claude Code talks straight to the remote /mcp). + The local `gbrain` CLI (`gbrain search`, `gbrain remote ping/doctor`, routed ops) still + requires OAuth client-credentials — `remote_mcp` + `callRemoteTool`/`getAccessToken` + in `src/core/mcp-client.ts` are OAuth-only. To let the local CLI work against the + remote with just a bearer token, widen `remote_mcp` with a bearer path (`auth: 'bearer'`, + `bearer_token`), short-circuit `getAccessToken` when `auth === 'bearer'` (skip discovery + + /token mint), and teach `initRemoteMcp` (`src/commands/init.ts`) to write a bearer-shaped + config. Then `gbrain connect --install` can also `bun install -g` gbrain + write the config. + Deferred per D1 (Tier 1 only this release). + ## v0.41.38.0 dream-postgres / source-pin follow-ups (v0.42+) Deferred from the v0.41.38.0 wave (code-callers/callees pin + dream-on-postgres). diff --git a/VERSION b/VERSION index 3b527a287..4aed115c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.1.0 \ No newline at end of file +0.42.2.0 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index e4a26748e..91002d6c5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -54,6 +54,15 @@ gbrain sync --watch # live-sync a git repo (autopilot mode) gbrain autopilot --install # background daemon for nightly enrichment ``` +**Wire this same local brain into your coding agent** — zero server, zero token: + +```bash +claude mcp add gbrain -- gbrain serve # Claude Code +codex mcp add gbrain -- gbrain serve # Codex +``` + +The agent spawns `gbrain serve` as a stdio subprocess against your local brain. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**. + ## 3. MCP server (any MCP client) ```bash @@ -61,9 +70,21 @@ gbrain serve # stdio MCP (Claude Desktop / Code / Cursor) gbrain serve --http # HTTP MCP with OAuth 2.1 + admin dashboard ``` +**Wire a coding agent to a remote brain in one command** (when you have an HTTP +server + a bearer token): `gbrain connect` prints a paste-ready setup block, or +`--install` runs it and smoke-tests the token. + +```bash +gbrain auth create "claude-code" +gbrain connect https://your-host/mcp --token gbrain_xxx # Claude Code (default) +gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex # Codex (env-var bearer) +gbrain connect https://your-host/mcp --agent perplexity --oauth --register # Perplexity (OAuth) +``` + Per-client setup guides live in [`docs/mcp/`](mcp/): - [`docs/mcp/CLAUDE_CODE.md`](mcp/CLAUDE_CODE.md) +- [`docs/mcp/CODEX.md`](mcp/CODEX.md) - [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md) - [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md) - [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md) diff --git a/docs/mcp/CLAUDE_CODE.md b/docs/mcp/CLAUDE_CODE.md index 54a02beb2..e61efefdd 100644 --- a/docs/mcp/CLAUDE_CODE.md +++ b/docs/mcp/CLAUDE_CODE.md @@ -1,5 +1,10 @@ # Connect GBrain to Claude Code +> New to this? The [Give your coding agent a memory](../tutorials/connect-coding-agent.md) +> tutorial walks both paths (local-from-nothing and connect-to-an-existing-brain) +> end to end, plus the brain-first protocol that makes it worth it. This page is +> the connection reference. + ## Option 1: Local (recommended, zero server needed) ```bash @@ -9,10 +14,44 @@ claude mcp add gbrain -- gbrain serve That's it. Claude Code spawns `gbrain serve` as a stdio subprocess. No server, no tunnel, no token needed. Works with both PGLite and Supabase engines. -## Option 2: Remote (access from any machine) +## Option 2: Remote, one command (fastest from a bearer token) -If you have GBrain running on a server with a public tunnel (see -[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md)): +If GBrain is running somewhere as an HTTP server (`gbrain serve --http`, see the +[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md)) and you have a bearer token, +let `gbrain connect` generate the wire-up for you. + +On the host (or anywhere `gbrain` is installed), mint a token and print the block: + +```bash +gbrain auth create "claude-code" +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx +``` + +`gbrain connect` prints a short, copy-paste block. Paste it into Claude Code — it +runs the `claude mcp add` for you and tells the agent to call `get_brain_identity` +and `list_skills` so it immediately knows what the brain can do. + +Already on the machine you want to wire up? Skip the copy-paste and let `connect` +do it directly, with a built-in token smoke-test: + +```bash +gbrain connect https://YOUR-DOMAIN.ngrok.app --token gbrain_xxx --install +``` + +(`--install` runs `claude mcp add`, then verifies the token by calling +`get_brain_identity` — so a wrong or expired token fails now, not silently on the +agent's first request. The URL is normalized: a bare host without `/mcp` gets it +appended; pass an explicit `https://` scheme.) + +Pipe-friendly machine output (token redacted unless `--show-token`): + +```bash +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --json +``` + +## Option 3: Remote, manual `claude mcp add` + +Equivalent to what `gbrain connect` generates, if you'd rather run it yourself: ```bash claude mcp add gbrain -t http \ @@ -20,8 +59,12 @@ claude mcp add gbrain -t http \ -H "Authorization: Bearer YOUR_TOKEN" ``` -Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token -from `gbrain auth create "claude-code"`. +Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token from +`gbrain auth create "claude-code"`. + +> A `gbrain auth create` token is a long-lived, full-access secret. Keep it +> private (it lands in `~/.claude.json`), and prefer a scoped/short-lived token +> where your host supports one. ## Verify @@ -33,6 +76,13 @@ search for [any topic in your brain] You should see results from your GBrain knowledge base. +> **`list_skills` returns nothing?** Skill discovery is gated by `mcp.publish_skills` +> on the host. New brains from `gbrain init` default it ON; brains upgraded from an +> older release stay OFF until you opt in. Enable it on the host with +> `gbrain config set mcp.publish_skills true`. The core tools (search, query, +> get_page, put_page, think, find_experts) work regardless. Note: `capture` is a +> CLI-only command, not an MCP tool — the agent writes over MCP with `put_page`. + ## Remove ```bash diff --git a/docs/mcp/CODEX.md b/docs/mcp/CODEX.md new file mode 100644 index 000000000..c2d7592a6 --- /dev/null +++ b/docs/mcp/CODEX.md @@ -0,0 +1,71 @@ +# Connect GBrain to Codex + +> New to this? The [Give your coding agent a memory](../tutorials/connect-coding-agent.md) +> tutorial walks both paths (local-from-nothing and connect-to-an-existing-brain) +> end to end, plus the brain-first protocol that makes it worth it. This page is +> the connection reference. + +Codex CLI (`@openai/codex`, v0.130+) supports remote streamable-HTTP MCP servers +with a bearer token read from an environment variable. The token lives in your +shell env, not in Codex's config file. + +## Fastest path: `gbrain connect` + +Run anywhere `gbrain` is installed (mint a token on the brain host first): + +```bash +gbrain auth create "codex" +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent codex +``` + +This prints a copy-paste block. Or wire it up directly and smoke-test the token: + +```bash +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent codex --install +``` + +`--install` runs `codex mcp add` for you, then makes one real call to the brain so +a wrong/expired token fails right away. Because Codex reads the token from the env +var at runtime, keep `GBRAIN_REMOTE_TOKEN` exported in your shell profile. + +## Manual setup + +```bash +export GBRAIN_REMOTE_TOKEN=gbrain_xxx +codex mcp add gbrain --url https://YOUR-DOMAIN.ngrok.app/mcp \ + --bearer-token-env-var GBRAIN_REMOTE_TOKEN +``` + +Codex stores the env-var *name* (`GBRAIN_REMOTE_TOKEN`), not the token itself, and +reads the value when it launches the MCP server. Add the `export` line to your +`~/.zshrc` / `~/.bashrc` so it's set in every session. + +## Verify + +In Codex, ask it to use the brain: + +``` +Call get_brain_identity, then search my brain for [topic]. +``` + +`get_brain_identity` confirms whose brain you're connected to; `list_skills` shows +everything it can do. + +> **`list_skills` empty?** It's gated by `mcp.publish_skills` on the host (default +> ON for `gbrain init` brains, OFF for brains upgraded from older releases). Enable +> it on the host: `gbrain config set mcp.publish_skills true`. The core tools +> (search, query, get_page, put_page, think, find_experts) work regardless. +> `capture` is CLI-only, not an MCP tool — write over MCP with `put_page`. + +## Remove + +```bash +codex mcp remove gbrain +``` + +## Notes + +- The token is a long-lived, full-access secret. Keep `GBRAIN_REMOTE_TOKEN` out of + version control and prefer a scoped token if your host supports one. +- Local stdio also works if you run the brain on the same machine: + `codex mcp add gbrain -- gbrain serve`. diff --git a/docs/mcp/PERPLEXITY.md b/docs/mcp/PERPLEXITY.md index c91520a9d..fb6595f89 100644 --- a/docs/mcp/PERPLEXITY.md +++ b/docs/mcp/PERPLEXITY.md @@ -1,20 +1,83 @@ # Connect GBrain to Perplexity Computer -Perplexity Computer supports remote MCP servers with bearer token authentication. +Perplexity Computer connects as a **remote** MCP client, so GBrain must be served +over HTTP and reachable at a public HTTPS URL. Perplexity does not run +`gbrain serve` (stdio) the way Claude Code does — it needs a reachable endpoint: -## Setup +``` +Perplexity Computer + → ngrok tunnel (https://YOUR-DOMAIN.ngrok.app/mcp) + → gbrain serve --http (built-in OAuth 2.1 transport) + → Postgres / PGLite +``` -1. Open Perplexity (requires Pro subscription) -2. Go to **Settings > Connectors** (or **MCP Servers**) +## 1. Serve GBrain over HTTP (host side) + +```bash +gbrain serve --http --port 3131 --bind 0.0.0.0 \ + --public-url https://YOUR-DOMAIN.ngrok.app +``` + +- **`--bind 0.0.0.0` is required.** Since v0.34, `--http` defaults to + `127.0.0.1`, so without it the tunnel reaches the server but the connection is + refused (`ECONNREFUSED`). +- **`--public-url` must match the tunnel.** The OAuth issuer in the discovery + metadata has to line up with the URL Perplexity actually hits (RFC 8414 §3.3), + or OAuth client-credentials auth fails. + +## 2. Expose it with a tunnel + +```bash +ngrok http 3131 --url YOUR-DOMAIN.ngrok.app +``` + +See the [ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for a persistent +tunnel. + +## 3. Create credentials + +Two supported auth paths. + +**OAuth 2.1 client credentials (recommended, v0.26.0+).** Perplexity is a cloud +service, so it holds whatever credential you give it. OAuth is the correct choice: +least-privilege scopes + short-lived rotating access tokens instead of a +long-lived full-access secret. Mint a client and print the connector fields in +one step (on the brain host): + +```bash +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --agent perplexity --oauth --register +``` + +Or register separately and pass the creds (works anywhere, no DB needed): + +```bash +gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write" +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --agent perplexity --oauth \ + --client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx +``` + +`connect --oauth` prints the **Issuer URL + Client ID + Client Secret** to paste +in step 4. + +**Legacy bearer token (simplest, best for local/personal):** + +```bash +gbrain auth create "perplexity" +gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent perplexity +``` + +(Perplexity is a GUI connector, so there's no `--install` — `connect` prints the +exact values to paste in step 4.) + +## 4. Add the connector in Perplexity + +1. Open Perplexity (requires Pro subscription). +2. Go to **Settings → Connectors** (or **MCP Servers**). 3. Add a new remote connector: - **URL:** `https://YOUR-DOMAIN.ngrok.app/mcp` - - **Authentication:** API Key / Bearer Token - - **Token:** your GBrain access token - (create one with `gbrain auth create "perplexity"`) -4. Save - -Replace `YOUR-DOMAIN` with your ngrok domain (see -[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for setup). + - **Authentication:** API Key / Bearer Token, or OAuth client credentials + - Paste the token (bearer) or `client_id` + `client_secret` (OAuth). +4. Save. ## Verify @@ -24,8 +87,14 @@ In a Perplexity conversation, ask it to use your brain: Use my GBrain to search for [topic] ``` +Have it call `get_brain_identity` (whose brain this is), then `list_skills` +(everything it can do). + ## Notes -- Perplexity Computer is available to Pro subscribers -- Both the Perplexity Mac app and web version support MCP connectors -- The Mac app also supports local MCP servers if you prefer `gbrain serve` (stdio) +- Perplexity Computer is available to Pro subscribers; both the Mac app and web + version support remote MCP connectors. +- The Mac app can also use a local MCP server (`gbrain serve` stdio) if you'd + rather not expose an HTTP endpoint. +- A `gbrain auth create` token is a long-lived, full-access secret. Keep it + private and prefer a scoped token where possible. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 75c50912b..9669938f2 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -7,13 +7,12 @@ Step-by-step walkthroughs that take you from zero to a working outcome. Concrete - [**Set up your personal AI agent + brain from zero**](personal-brain.md) — the canonical solo install. Two GitHub repos, a Telegram bot, AlphaClaw on Render, OpenClaw + GBrain + Supabase. End-to-end in about 2 hours; about $100 to $150 a month sustained. The full-stack install I'd run today. - [**Set up GBrain as your company brain**](company-brain.md) — federated, multi-user, OAuth-scoped institutional memory for a 10-50 person team. Three sources (shared / customers / internal-only), per-user scope, first synthesized query as a teammate. About 90 minutes end-to-end, about $5 in API calls for the demo, under $100 a month sustained for a 25-person company. - [**Auto-improve a skill with `gbrain skillopt`**](improving-skills-with-skillopt.md) — treat a `SKILL.md` as the trainable parameter of a frozen agent. Write your first benchmark from scratch (the part everyone gets stuck on), preview the cost, run the optimizer, read accepted vs no_improvement vs aborted, and accept a measurably better skill. About 20 minutes, about $1 in API calls. Reference: [`../guides/skillopt.md`](../guides/skillopt.md). +- [**Give your coding agent a memory: GBrain + Claude Code / Codex**](connect-coding-agent.md) — the two-funnel walkthrough for coding-agent users. Path A: connect Claude Code / Codex to a brain you already run (OpenClaw, Hermes, any `gbrain serve --http`). Path B: start from nothing with a 2-second local PGLite brain. Both end with the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits (brain-first lookup, ambient capture, briefing-from-your-brain, whoknows) that make it worth it. About 10 minutes. ## In progress These are the next tutorials on the roadmap. Open an issue if one of them is the one you need most; that's how we'll prioritize. -- **Connect GBrain to your existing agent** — for users who already run [OpenClaw](https://github.com/garrytan/openclaw), [Hermes](https://github.com/garrytan/hermes), Claude Code, Cursor, or any MCP-aware client. Wire GBrain in as the memory layer, scaffold the 43 skills, see brain-first lookup fire on the next message your agent gets. - - **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find_trajectory`, and `gbrain founder scorecard` on real workflows. - **Migrate your existing vault into GBrain** — for Notion / Obsidian / Roam users with a vault that doesn't match GBrain's default layout. Walks through `gbrain schema detect` → `suggest` → `review-candidates` so the brain learns your shape instead of forcing you to learn its. diff --git a/docs/tutorials/connect-coding-agent.md b/docs/tutorials/connect-coding-agent.md new file mode 100644 index 000000000..a98e87b2b --- /dev/null +++ b/docs/tutorials/connect-coding-agent.md @@ -0,0 +1,235 @@ +# Give your coding agent a memory: GBrain + Claude Code / Codex + +Coding agents got very good at code. They're still amnesiac about everything +else. Claude Code and Codex forget your last conversation, can't tell you what +you decided three meetings ago, and re-derive context you already have written +down somewhere. GBrain is the retrieval layer that fixes that: search, synthesis, +and a self-wiring knowledge graph, wired into your agent over MCP. + +There are two ways to do this. Pick the one that matches where you are: + +- **Path A — I already run a brain** (OpenClaw, Hermes, or any `gbrain serve` + host) and I want my Claude Code / Codex to reach the same brain. → [jump to Path A](#path-a-connect-an-agent-to-a-brain-you-already-have) +- **Path B — I have nothing yet.** Spin up a local brain in 2 seconds and wire it + into my coding agent. → [jump to Path B](#path-b-start-from-nothing-local-brain-local-agent) + +Both end in the same place: an agent that searches your brain before it answers, +and writes new knowledge back as you work. The last section, +[Now make it actually useful](#now-make-it-actually-useful), is the same for both +and is the part that changes how you work. + +Prerequisite for either path: `bun install -g github:garrytan/gbrain`. + +--- + +## Path A: connect an agent to a brain you already have + +You already have a populated brain (the OpenClaw / Hermes case: it's on your +agent host, full of meetings, people, and ideas). You want Claude Code on your +laptop, and Codex too, to query it. This is the remote path: the host serves +HTTP, your laptop agents connect with a token. + +### A1. On the host: serve over HTTP + +If your host isn't already serving HTTP MCP, start it: + +```bash +gbrain serve --http --bind 0.0.0.0 --public-url https://your-host.example.com +``` + +Two flags matter and people skip them: + +- **`--bind 0.0.0.0`** — the default bind is `127.0.0.1` (loopback only), which + silently refuses every remote connection. If your agent "can't reach the + brain" and you didn't pass this, that's why. `gbrain serve --http` warns you at + startup when `--public-url` is set without `--bind`. +- **`--public-url`** — the externally reachable HTTPS URL (your Render/Railway + URL, ngrok domain, Tailscale Funnel, etc.). It's the issuer the OAuth/MCP + layer advertises. + +Watch the startup banner. It now prints a `Skills:` line: + +``` +║ Skills: published ║ +``` + +If it says `not published`, your connected agents will be able to search and +write but won't see your skill catalog (the OpenClaw skills that make your setup +special). Turn it on: + +```bash +gbrain config set mcp.publish_skills true +``` + +(New brains from `gbrain init` default this ON. Brains upgraded from before +v0.41.36 stay OFF until you opt in, so this is the common gotcha for existing +OpenClaw users.) + +### A2. On the host: mint a token + +```bash +gbrain auth create "laptop-agents" +``` + +Copy the `gbrain_…` token it prints. It's a long-lived, full-access secret. Treat +it like a password; prefer a scoped OAuth client for anything cloud-hosted (see +[DEPLOY.md](../mcp/DEPLOY.md)). + +### A3. On the laptop: one command per agent + +```bash +# Claude Code +gbrain connect https://your-host.example.com/mcp --token gbrain_xxx --install + +# Codex +gbrain connect https://your-host.example.com/mcp --token gbrain_xxx --agent codex --install +``` + +`--install` runs the agent's `mcp add` for you AND smoke-tests the token: it +actually calls `get_brain_identity` before handing off, so a wrong or expired +token fails right now, not silently on the agent's first request. You'll see: + +``` +Added MCP server 'gbrain' -> https://your-host.example.com/mcp. +Verified: {"version":"0.42.x","engine":"postgres","page_count":146646,...} +``` + +Drop `--install` to print a paste-ready block instead (useful when the host and +the agent are different machines, or you want to read before you run). Codex +reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands +in Codex's config file. Keep that variable exported in your shell profile. + +### A4. Verify + +In the agent: *"Call get_brain_identity, then search my brain for [a topic you +know is in there]."* You should get your own pages back. Done. + +Full per-client detail: [Claude Code](../mcp/CLAUDE_CODE.md), +[Codex](../mcp/CODEX.md), [Perplexity](../mcp/PERPLEXITY.md). + +--- + +## Path B: start from nothing (local brain, local agent) + +No OpenClaw, no server, no token. The lowest-friction path in the whole product: +a local PGLite brain in the same process your agent spawns. Zero server, zero +tunnel. + +### B1. Create a local brain + +```bash +gbrain init --pglite # 2 seconds; embedded Postgres via WASM, no Docker +``` + +### B2. Put something in it + +A brain with nothing in it answers nothing, so an empty brain on day one feels +broken. Two ways to fill it: + +```bash +# Bulk-import a folder of markdown you already have: +gbrain import ~/notes/ + +# Or capture as you go (one thought at a time): +gbrain capture "Decided to use PGLite as the default engine: zero-config beats Postgres for <1000 files." +``` + +You don't have to import everything up front. The capture-as-you-go habit (see +the next section) means the brain fills with the decisions and context you +generate while working, and is genuinely useful by day two. + +### B3. Wire it into your coding agent + +```bash +# Claude Code +claude mcp add gbrain -- gbrain serve + +# Codex +codex mcp add gbrain -- gbrain serve +``` + +That's the whole wire-up. No token, no URL, no tunnel. The agent spawns +`gbrain serve` as a stdio subprocess and talks to your local brain directly. + +### B4. Verify + +In the agent: *"search my brain for PGLite"* (or whatever you just captured). You +get the page back. The same brain is now query-able from the CLI +(`gbrain query "..."`) and from your agent. + +--- + +## Now make it actually useful + +Connecting is the easy part. The value comes from teaching your agent a few +habits. These are the patterns that turn a coding agent into a knowledge-aware +one. Paste the protocol below into your agent's instructions file +(`CLAUDE.md` for Claude Code, `AGENTS.md` for Codex / Cursor / others), then lean +on the patterns. + +### The brain-first protocol (paste this in) + +```markdown +## Brain-first protocol + +You have a knowledge brain connected over MCP. Before answering any question +about people, companies, decisions, projects, or past context: + +1. **Search first.** Call `search` (or `query` for a synthesized answer) against + the brain BEFORE answering from memory or asking me. If the brain has the + answer, use it. Never ask "who is X?" or "what did we decide about Y?" before + searching — the brain probably already knows. +2. **Write back.** When I make a decision, mention a new person/company, or land + on an idea worth keeping, write it to the brain with `put_page` (entity pages + under people/, companies/; decisions under decisions/ or notes/). One insight, + one page, linked. +3. **Cite.** When you answer from the brain, name the page you used. +``` + +### The four patterns worth stealing + +These come straight from a production OpenClaw setup. They translate directly to +any coding agent with GBrain connected: + +**1. Brain-first lookup (never ask what you can retrieve).** The single highest- +value habit. Before the agent asks you "which repo?" or "who owns this?", it +searches. Try: *"What did we decide about the auth rewrite?"* and watch it pull +the decision page instead of asking you to re-explain. + +**2. Ambient capture (your brain as a side effect of working).** Don't make +saving a separate chore. Tell the agent: *"As we work, capture any decision or +new idea to the brain without interrupting."* After a month of this, you have +hundreds of linked pages and patterns you didn't know were there. + +**3. Briefing from your brain (not from the internet).** *"What do I need to know +before my 2pm with the Acme team?"* pulls your meeting history, the people, +what's still open, what the brain doesn't know yet. The agent does your prep +because it read your context. (`query` gives you the synthesized answer with +citations; this is the example on the [README](../../README.md).) + +**4. whoknows (expertise routing).** *"Who do I know who's shipped a rate +limiter in Postgres?"* The `find_experts` tool ranks people in your brain by +relevance + recency. Useful the moment your brain has more than a handful of +people in it. + +That's the spine of it. Two commands to connect, one protocol to paste, four +habits to build. Your agent stops being amnesiac. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Agent "can't reach the brain" (Path A) | `gbrain serve --http` bound to loopback | Restart with `--bind 0.0.0.0` | +| `list_skills` returns nothing / errors | Skill publishing OFF on the host | `gbrain config set mcp.publish_skills true` | +| Token rejected on first call | Wrong/expired token | Re-mint with `gbrain auth create`; `--install` smoke-tests it for you | +| `unknown tool: capture` | `capture` is CLI-only, not an MCP tool | Use `put_page` over MCP; `capture` only on the CLI | +| Empty results (Path B) | Brain has nothing in it yet | `gbrain import ~/notes/` or `gbrain capture "..."` | + +## Next steps + +- Go full autonomous: the overnight enrichment daemon ([dream cycle](../../CHANGELOG.md)) fixes citations, dedupes people, builds scorecards while you sleep. See `gbrain autopilot --install`. +- Run a real agent platform on top: [personal-brain tutorial](personal-brain.md). +- Scale to a team: [company-brain tutorial](company-brain.md). +- Every MCP client's exact setup: [`docs/mcp/`](../mcp/). diff --git a/llms-full.txt b/llms-full.txt index 65337eaf3..666a51061 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -400,7 +400,8 @@ strict behavior when unset. - `src/commands/serve.ts` (v0.31.3) — `gbrain serve` stdio MCP entrypoint with idempotent shutdown across every parent-disconnect signal. Stdio EOF, SIGTERM, SIGINT, SIGHUP, and parent-process death (every reparent case — PID 1, launchd subreaper, systemd, tmux, or a parent shell with `PR_SET_CHILD_SUBREAPER`) all funnel into one `cleanup(reason)` path that releases the engine and the PGLite write-lock dir within 5 seconds. Pre-v0.31.3 the stdio MCP server held the lock indefinitely after Claude Desktop / Cursor / launchd-managed gateways disconnected, forcing a 5-minute stale-lock wait on the next start. Watchdog reparent check is `getParentPid() !== initialParentPid` (capturing the initial ppid once at install time and firing on any change); the previous `=== 1` check missed the subreaper case under launchd / systemd. Bun's `process.ppid` cache is stale across reparenting (see [oven-sh/bun#30305](https://github.com/oven-sh/bun/issues/30305)) so `getParentPid()` runs `spawnSync('ps', ['-o', 'ppid=', '-p', PID])` per tick to read the live kernel PPID. Startup probe verifies `ps` is on PATH; if not (stripped containers, busybox without procps), the watchdog skips installing AND emits a loud `[gbrain serve] watchdog disabled: ps unavailable, parent-death detection unavailable — child will rely on stdin EOF / signals only` stderr line so operators see the degraded mode at boot. Pinned by `test/serve-stdio-lifecycle.test.ts` (22 cases). Closes #413, #446. Credit @Aragorn2046 (origin features in #591) and @seungsu-kr (rebased submitter, Bun ppid workaround). - `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. **v0.26.9 RFC 6749/7009 hardening pass:** F1+F2 fold `client_id` atomically into the `DELETE WHERE` clauses for both auth-code exchange and refresh rotation — pre-fix the post-hoc client compare burned the row on wrong-client paths so the legitimate client couldn't retry. F3 enforces refresh-scope-subset against the original grant on the row (RFC 6749 §6), not the client's currently-allowed scopes — fixes the case where revoking a scope from a client wouldn't shrink the agent's existing refresh tokens. F4 binds `client_id` on `revokeToken` so a client can only revoke its own tokens (RFC 7009 §2.1). F7c validates the `/token` request's `redirect_uri` against the value stored at `/authorize` (RFC 6749 §4.1.3) — empty-string treated as missing rather than wildcard match (adversarial-review fix). F5 swaps bare `catch {}` blocks in `verifyAccessToken` and `getClient` for `isUndefinedColumnError` from `src/core/utils.ts` — only SQLSTATE 42703 falls through to legacy fallback; lock timeouts and network blips throw and surface. F6 makes `sweepExpiredTokens()` actually return the count via `RETURNING 1` + array length, not a fire-and-forget zero. F12 adds `dcrDisabled` constructor option so `serve-http.ts` can disable the `/register` endpoint without monkey-patching the router. **v0.26.2:** module-private `coerceTimestamp()` boundary helper at the top of the file normalizes postgres-driver-as-string BIGINT columns to JS numbers at every read site (5 call sites: `getClient` L112+L113 for DCR `/register` RFC 7591 §3.2.1 numeric timestamps, `exchangeRefreshToken` L274 + `verifyAccessToken` L296+L303 for the SDK's `typeof === 'number'` bearerAuth check). Throws on non-finite input (NaN/Infinity) so corrupt rows fail loud at the boundary instead of riding through as `expiresAt: NaN`; returns undefined for SQL NULL so callers decide NULL semantics explicitly (refresh + access token paths treat NULL as expired). Helper intentionally NOT promoted to `src/core/utils.ts` — codex review flagged repo-wide BIGINT precision-loss risk for a generic helper. **v0.34.1.0 (#909):** `registerClient` honors `token_endpoint_auth_method: "none"` (RFC 7591 §3.2.1) — public PKCE clients (Claude Code, Cursor, every other PKCE-first MCP client) store `client_secret_hash = NULL` and the response payload omits `client_secret` entirely. Confidential clients (default `client_secret_post` and explicit `client_secret_basic`) keep their one-time-reveal shape. `getClient` correctly normalizes a NULL `client_secret_hash` to JS `undefined` so the SDK's clientAuth path accepts the public client at `/token`. **v0.34.1.0 (#861 + #876):** `verifyAccessToken` JOINs `oauth_clients.source_id` (write scope, scalar) + `oauth_clients.federated_read` (read scope, TEXT[]) and surfaces both on the returned `AuthInfo`. Pre-v60 / pre-v61 brains degrade gracefully via `isUndefinedColumnError` fallback so the upgrade chain is non-blocking on legacy DBs. - `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries. -- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. **v0.31.3 (#681):** every SQL site routes through `sqlQueryForEngine(engine)` from `src/core/sql-query.ts` (and `executeRawJsonb` for the takes-holders `permissions` JSONB column) so `gbrain auth` works against PGLite brains. Pre-fix, every call hit the postgres.js singleton via `getConn()` and silently failed (or wrote to the wrong DB) when the active engine was PGLite. The takes-holders write goes through `executeRawJsonb(engine, sql, [name, hash], [{takes_holders:[...]}])` which round-trips with `jsonb_typeof = 'object'` instead of the pre-v0.31.3 quoted-string shape. **v0.34.1.0 (#876):** `register-client` accepts `--source ` (write authority, scalar) and `--federated-read ` (read scope, array). The output prints the resolved `Write source` and `Federated reads` for the registered client. Pre-v0.34 clients backfill to `source_id='default'` via migration v60 so existing deployments keep their v0.33 effective behavior verbatim. +- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. **v0.31.3 (#681):** every SQL site routes through `sqlQueryForEngine(engine)` from `src/core/sql-query.ts` (and `executeRawJsonb` for the takes-holders `permissions` JSONB column) so `gbrain auth` works against PGLite brains. Pre-fix, every call hit the postgres.js singleton via `getConn()` and silently failed (or wrote to the wrong DB) when the active engine was PGLite. The takes-holders write goes through `executeRawJsonb(engine, sql, [name, hash], [{takes_holders:[...]}])` which round-trips with `jsonb_typeof = 'object'` instead of the pre-v0.31.3 quoted-string shape. **v0.34.1.0 (#876):** `register-client` accepts `--source ` (write authority, scalar) and `--federated-read ` (read scope, array). The output prints the resolved `Write source` and `Federated reads` for the registered client. Pre-v0.34 clients backfill to `source_id='default'` via migration v60 so existing deployments keep their v0.33 effective behavior verbatim. **v0.42.2.0:** the bare `gbrain auth create ` form (no `--takes-holders` flag) was silently dropping the name and printing usage instead of minting a token. The positional-vs-flag parse is extracted into the exported pure `parseAuthCreateArgs(rest)` so it's unit-testable — the pre-fix inline version used `rest[takesIdx + 1]` which resolved to `rest[0]` (the name) when `takesIdx === -1`, excluding the name from the positional search. Pinned by `test/auth-create-args.test.ts`. +- `src/commands/connect.ts` + `src/core/connect-probe.ts` (v0.42.2.0) — `gbrain connect [--token ]` one-command coding-agent onboarding from a bearer token. Turns an MCP URL + token into a paste-ready `claude mcp add ... -H "Authorization: Bearer ..."` block (default) or, with `--install`, runs it directly and smoke-tests the token. Direct HTTP MCP — Claude Code talks straight to a remote `gbrain serve --http`; no local install or thin-client OAuth config needed for the connection itself (the local CLI over a bearer token is deferred T7 in TODOS). Token resolution: `--token` > `$GBRAIN_REMOTE_TOKEN` > (print mode) a `` placeholder / (install mode) error. The generated block tells the agent to call `get_brain_identity` + `list_skills` (the `LEARN_INSTRUCTION` export) so it self-orients on what the brain is and can do, with a core-tools fallback list for hosts that haven't enabled skill publishing. URL normalization appends `/mcp` to a bare host but REJECTS a scheme-less host (so you can't silently point at the wrong thing); pure helpers (`isLinkLocalOrMetadata`, URL parse, render) are unit-tested. Flags: `--token`, `--name ` (default `gbrain`, validated against `NAME_RE`), `--agent claude-code|codex|perplexity|generic`, `--install`, `--yes` (required for `--install` in a non-TTY), `--force` (replace an existing same-name server), `--json` (token redacted unless `--show-token`), `--timeout-ms`. `connect` is in `CLI_ONLY` + `CLI_ONLY_SELF_HELP` (prints its own `--help`); dispatched in `cli.ts:handleCliOnly` with no local DB connect (print mode touches nothing; `--install` talks to the remote). **Multi-agent (v0.42.2.0):** `AGENT_SPECS` drives per-agent rendering + `--install` dispatch. `claude-code` → `buildClaudeMcpAddArgv` (literal `-H "Authorization: Bearer "`). `codex` → `buildCodexMcpAddArgv` = `codex mcp add --url --bearer-token-env-var GBRAIN_REMOTE_TOKEN` (Codex reads the token from the env var at runtime — never written to its config; `--install` runs `codex mcp add` and prints an `export GBRAIN_REMOTE_TOKEN` hint when the live env doesn't already carry it). `perplexity` + `generic` are `installable:false` (perplexity prints Settings → Connectors GUI steps; generic prints URL + header) and reject `--install`. **OAuth (`--oauth`, `supportsOAuth:true` agents = perplexity/generic only):** emits an OAuth 2.1 client-credentials connector block (Issuer URL via `issuerFromMcpUrl` = mcp-url minus `/mcp`, Client ID, Client Secret) instead of a bearer header — the correct path for a third-party cloud connector (least-privilege scopes + short-lived rotating tokens vs a long-lived full-access secret). Creds from `--client-id`/`--client-secret` (BYO, runs anywhere) or `--register` (host-side: `deps.registerOAuthClient` shells `gbrain auth register-client --grant-types client_credentials --scopes --token-endpoint-auth-method client_secret_post` and parses `Client ID:`/`Client Secret:`). `--oauth` is rejected for claude-code/codex and is incompatible with `--install`. `buildJson` oauth shape: `auth:'oauth'`, `issuer_url`, `client_id`, `client_secret` (redacted unless `--show-token`), `scopes`. Full OAuth chain (register → connect --oauth → OAuth discovery → `/token` client_credentials mint → `get_brain_identity`) is E2E-proven in `connect-bearer.test.ts` (client registered in `beforeAll` before serve takes the PGLite single-writer lock). `cmdString(binary, argv)` POSIX-single-quotes args (the codex `export ` line is single-quoted too). `buildJson` is a generic shape (`agent`, `command`/`command_argv` null for perplexity/generic, `header`, `env_var`); the codex `command` carries only the env-var name, never the token. `ConnectDeps` = `{isTTY, promptYesNo, hasBinary(bin), runBinary(bin, argv), probe, env(name)}` — binary-generic so `claude` and `codex` share the path; `env` injectable so tests control `GBRAIN_REMOTE_TOKEN`. Per-agent docs: `docs/mcp/CODEX.md` (new), `docs/mcp/PERPLEXITY.md` (connect fast-path added). Security posture: the rendered command single-quotes the token so shell metacharacters in a token can't run code when pasted; the token is validated before it lands in an HTTP header; link-local / cloud-metadata addresses (incl. IPv4-mapped IPv6 `::ffff:169.254.x.x` and AWS IMDSv2-over-IPv6 `fd00:ec2::254`) are refused as a token-exfil guard while localhost / RFC1918 / LAN stay allowed (self-hosted is a supported topology); the token is redacted from all error output. `src/core/connect-probe.ts` is the raw-bearer MCP smoke probe backing `--install`: connects the official MCP SDK `Client` over `StreamableHTTPClientTransport` with a STATIC `Authorization` header (no OAuth, no discovery — distinct from `mcp-client.ts:callRemoteTool` which is OAuth-only, and from `remote-mcp-probe.ts:smokeTestMcp` which only sends `initialize`), runs the full `initialize` handshake via `client.connect()`, then calls `get_brain_identity` (read-scope, non-localOnly) to prove a tool call actually round-trips. Never throws — every failure maps to a discriminated `{ ok: false, reason: 'auth' | 'unreachable' | 'timeout' | 'tool_error' | 'unknown', message }` so the caller renders a precise warning (a wrong/expired token fails at setup, not silently on the agent's first request). `DEFAULT_PROBE_TIMEOUT_MS = 15_000` is the single source of truth shared with `connect.ts`. Pinned by `test/connect.test.ts` (pure-helper + render coverage, all four agents) + `test/e2e/connect-bearer.test.ts`: the raw-bearer probe against a live `gbrain serve --http` AND real-CLI coverage that drives the actual `claude` + `codex` binaries through `connect --install` against the live server (sandboxed `HOME` / `CODEX_HOME`, asserts `claude mcp get` / `codex mcp get` registered it + the token never lands in Codex config; skips when a binary is absent, e.g. CI). Perplexity is a GUI connector so it's print-asserted only (no CLI to wire E2E). Surfaced in `docs/mcp/CLAUDE_CODE.md`, `docs/mcp/CODEX.md`, `docs/mcp/PERPLEXITY.md`, and the README. **v0.42.2.0 follow-up (two-funnel onboarding):** `LEARN_INSTRUCTION` no longer names `capture` — it's a CLI-only command, NOT an MCP tool, so an agent that followed the block hit "unknown tool"; the list now names `put_page` (the real MCP write tool), pinned by a `connect.test.ts` regression block AND by the new `test/e2e/serve-stdio-roundtrip.test.ts` which spawns real `gbrain serve` (stdio) against a fresh `init --pglite` brain and drives the official MCP SDK client through `initialize` → `tools/list` → `tools/call` (`get_brain_identity` + `search`), asserting the advertised core-tool set matches what the server exposes (and that `capture` is NOT advertised) — the local stdio funnel's first e2e coverage. `src/commands/serve-http.ts` adds an exported pure `skillPublishStatus(publishSkills)` consumed by the startup banner (`Skills: published / not published` line) + a one-line stderr nudge (`gbrain config set mcp.publish_skills true`) when publishing is OFF, so an operator wiring a coding agent to an existing brain sees their skill catalog is invisible to connected agents instead of discovering it via an empty `list_skills` (pinned by `test/serve-skills-publish-nudge.test.ts`). The user-facing walkthrough is `docs/tutorials/connect-coding-agent.md` (Path A: connect to an existing brain; Path B: start from nothing, local stdio) with the brain-first protocol + the four translatable habits; README has a "Quick start: Claude Code or Codex" fork and `INSTALL.md` shows the one-command wire-up at the standalone-CLI section. The pre-existing `test/audit/batch-retry-audit.test.ts` ENOENT case was made hermetic (it had read the real `~/.gbrain/audit`). - `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally. - `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry ` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed. **v0.41.37.0 (#1605):** new `in-process.ts` exports `runMigrateOnlyCore({timeoutMs?})` — the single source of truth for "bring schema to head" (`configureGateway` → `createEngine` → `connect` → `initSchema` → `disconnect`, idempotent, 600s wall-clock guard `MIGRATE_ONLY_TIMEOUT_MS`, throws `MigrateOnlyError` on no-config / timeout). The migration orchestrators' 9 schema phases AND `init.ts:initMigrateOnly` (the `gbrain init --migrate-only` CLI path) both delegate to it, so the schema bring-up can't drift between them. Previously each phase shelled out to a child `gbrain init --migrate-only` via `execSync`; on Windows + bun + Supabase pooler the SPAWNED child died with `getaddrinfo ENOTFOUND` before it could connect (a bun-on-Windows child-process DNS failure, NOT an env-propagation bug — the parent connects fine with `env: process.env`). Running in-process removes the spawn entirely. `runGbrainSubprocess` is the diagnostic wrapper for the REMAINING non-schema spawns (extract/repair/stats): captures child stderr (64MB buffer) and folds it into the thrown error so a Windows failure shows the real `getaddrinfo ENOTFOUND` line instead of a bare `Command failed`. Pinned by `test/migration-in-process.serial.test.ts`. **v0.41.37.0 (#1581):** `v0_13_1.ts:phaseCGrandfather` rewritten from a per-page `getPage` + `putPage` loop (which hung CPU-bound 70+ min on an 82K-page PGLite brain) to a CHUNKED bulk SQL pass. Three correctness properties the per-page loop and a naive single bulk UPDATE both got wrong: (1) keyed on `pages.id` (globally unique PK), NOT slug — slug uniqueness is `(source_id, slug)`, so a slug-batched UPDATE would mutate same-slug pages across other sources; (2) filters `deleted_at IS NULL` so tombstones aren't grandfathered; (3) chunked in `CHUNK_SIZE` batches (DELETE_BATCH_SIZE convention) so lock-hold stays bounded instead of one giant transaction. The rollback log carries source identity (`{id, slug, source_id, pre_frontmatter}`) so a rollback is unambiguous across sources. Idempotent + resumable (each UPDATE flips its rows out of `GRANDFATHER_WHERE`). Completes ~1-2s on the 82K-page brain. Pinned by `test/migrations-v0_13_1-grandfather.test.ts`. - `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent. @@ -2671,7 +2672,7 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/README.md # GBrain -**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box. +**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box. Run a full autonomous agent on top of it, or just wire it into Claude Code or Codex as a supercharged retrieval layer in one command; either way your coding agent stops being amnesiac about everything that isn't code. I'm Garry Tan, President and CEO of Y Combinator. I built GBrain to run my own AI agents. It's the production brain behind my OpenClaw and Hermes deployments: **146,646 pages, 24,585 people, 5,339 companies**, 66 cron jobs running autonomously. My agent ingests meetings, emails, tweets, voice calls, and original ideas while I sleep. It enriches every person and company it encounters. It fixes its own citations and consolidates memory overnight. I wake up smarter than when I went to bed — and so will you. @@ -2756,9 +2757,29 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads 43 s > **Never set up an AI agent platform before?** The [personal-brain tutorial](docs/tutorials/personal-brain.md) walks the whole path end-to-end — picking OpenClaw vs Hermes, deploying it, pointing it at INSTALL_FOR_AGENTS.md, getting the API keys, and verifying the first query. Start there if any of the above is new. -### Install it into your existing agent +### Quick start: Claude Code or Codex -Already running Codex, Claude Code, Cursor, or another coding agent? Paste the same instruction in: +Already running Claude Code or Codex? There are two ways to wire GBrain in, depending on what you want. + +**Just want a memory for your coding agent (recommended starting point).** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel: + +```bash +gbrain init --pglite # 2-second local brain (no Docker) +claude mcp add gbrain -- gbrain serve # or: codex mcp add gbrain -- gbrain serve +``` + +**Already have a brain on a remote host** (OpenClaw, Hermes, or any `gbrain serve --http`)? Point your laptop agents at it with one command each — `--install` wires it up and smoke-tests the token before handoff: + +```bash +gbrain connect https://your-host/mcp --token gbrain_xxx --install # Claude Code +gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex +``` + +**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — both paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work. + +### Install the full autonomous setup into your existing agent + +Want the whole thing — local brain, 43 skills, the overnight dream cycle that enriches while you sleep? Paste this into Codex, Claude Code, Cursor, or another coding agent: ``` Retrieve and follow the instructions at: @@ -2783,11 +2804,12 @@ Postgres-at-scale, Supabase, and thin-client setup paths live in [`docs/INSTALL. GBrain exposes 30+ tools over MCP (stdio and HTTP). The specific snippet depends on which client you use: -- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — one command: `claude mcp add gbrain -- gbrain serve`. Zero server, zero tunnel. +- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token). +- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config. - **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config. - **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers. - **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors. -- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — Settings → Connectors → add the URL + bearer token. Pro subscription required. +- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required. - **[ChatGPT](docs/mcp/CHATGPT.md)** — uses OAuth 2.1 with PKCE (the hard requirement). Register a `chatgpt` client from the admin dashboard with grant type `authorization_code`. For the HTTP server itself: diff --git a/package.json b/package.json index baaca2814..b4cf523bb 100644 --- a/package.json +++ b/package.json @@ -141,5 +141,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.42.1.0" + "version": "0.42.2.0" } diff --git a/scripts/e2e-test-map.ts b/scripts/e2e-test-map.ts index 1900a86fd..cca6fb6b4 100644 --- a/scripts/e2e-test-map.ts +++ b/scripts/e2e-test-map.ts @@ -76,6 +76,10 @@ export const E2E_TEST_MAP: Record = { "src/mcp/**": ["test/e2e/mcp.test.ts", "test/e2e/http-transport.test.ts"], // Integrity batch-load fast path. "src/commands/integrity.ts": ["test/e2e/integrity-batch.test.ts"], + // gbrain connect — raw-bearer MCP smoke probe exercised end-to-end against + // a real serve --http (PGLite), so changes to either feed it. + "src/commands/connect.ts": ["test/e2e/connect-bearer.test.ts"], + "src/core/connect-probe.ts": ["test/e2e/connect-bearer.test.ts"], // Upgrade chains migration ledger; touches both runners. "src/commands/upgrade.ts": [ "test/e2e/upgrade.test.ts", diff --git a/src/cli.ts b/src/cli.ts index e7037fc42..a36fabe08 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,7 @@ for (const op of operations) { } // CLI-only commands that bypass the operation layer -const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'skillopt']); +const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt']); // CLI-only commands whose handlers print their own --help text. These are // excluded from the generic short-circuit so detailed per-command and // per-subcommand usage stays reachable. @@ -75,6 +75,9 @@ const CLI_ONLY_SELF_HELP = new Set([ // describing segment splitting + checkpointing + budget caps + the // unified types config story. Route around the generic short-circuit. 'extract-conversation-facts', + // `gbrain connect --help` prints its own usage (flags + examples) from + // runConnect; route around the generic one-line short-circuit. + 'connect', ]); async function main() { @@ -906,6 +909,14 @@ async function handleCliOnly(command: string, args: string[]) { await runRemote(args); return; } + if (command === 'connect') { + // No local DB: connect generates/wires a Claude Code MCP connection to a + // REMOTE gbrain over HTTP from a bearer token. Print mode touches nothing; + // --install talks to the remote, not the local engine. + const { runConnect } = await import('./commands/connect.ts'); + await runConnect(args); + return; + } if (command === 'upgrade') { const { runUpgrade } = await import('./commands/upgrade.ts'); await runUpgrade(args); @@ -2071,6 +2082,8 @@ ADMIN --token-ttl N Access token TTL in seconds (default: 3600) --enable-dcr Enable Dynamic Client Registration --public-url URL Public issuer URL (required behind proxy/tunnel) + connect --token Wire Claude Code to a remote gbrain (bearer token) + [--install] [--json] Print the paste-ready command, or --install to run it call '' Raw tool invocation version Version info --tools-json Tool discovery (JSON) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d949c93ef..f568eec46 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -460,17 +460,32 @@ async function registerClient(name: string, args: string[]) { * direct-script path (see bottom of file) so `bun run src/commands/auth.ts` * still works. */ +/** + * Parse `auth create` args into `{ name, takesHolders }`. + * + * Exported + pure so the positional-vs-flag logic is unit-testable. Only + * excludes the --takes-holders VALUE from the positional search when the flag + * is present — the pre-v0.41 inline version used `rest[takesIdx + 1]` which + * resolved to `rest[0]` when `takesIdx === -1`, silently dropping the name on + * the bare `gbrain auth create ` form. + */ +export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[] } { + const takesIdx = rest.indexOf('--takes-holders'); + const takesHolders = takesIdx >= 0 && rest[takesIdx + 1] + ? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean) + : undefined; + const takesValue = takesIdx >= 0 ? rest[takesIdx + 1] : undefined; + const positional = rest.find(a => !a.startsWith('--') && a !== takesValue); + return { name: positional || '', takesHolders }; +} + export async function runAuth(args: string[]): Promise { const [cmd, ...rest] = args; switch (cmd) { case 'create': { // v0.28: optional --takes-holders world,garry,brain (default: world only) - const takesIdx = rest.indexOf('--takes-holders'); - const takesHolders = takesIdx >= 0 && rest[takesIdx + 1] - ? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean) - : undefined; - const positional = rest.find(a => !a.startsWith('--') && a !== rest[takesIdx + 1]); - await create(positional || '', { takesHolders }); + const parsed = parseAuthCreateArgs(rest); + await create(parsed.name, { takesHolders: parsed.takesHolders }); return; } case 'list': await list(); return; diff --git a/src/commands/connect.ts b/src/commands/connect.ts new file mode 100644 index 000000000..5d642e7ce --- /dev/null +++ b/src/commands/connect.ts @@ -0,0 +1,766 @@ +/** + * `gbrain connect` — one-command coding-agent onboarding from a bearer token + * (or OAuth 2.1 client credentials). + * + * Turns an MCP URL + credential into a paste-ready block (or wires it up + * directly with --install) that connects a coding agent straight to a remote + * `gbrain serve --http` and teaches it to self-orient via `get_brain_identity` + * + `list_skills`. Direct HTTP MCP — no local install or thin-client config + * needed for the connection. + * + * gbrain connect [--token ] [--name gbrain] + * [--agent claude-code|codex|perplexity|generic] + * [--oauth [--register | --client-id ID --client-secret SECRET] [--scopes "read write"]] + * [--install] [--yes] [--json] [--show-token] [--force] + * [--timeout-ms N] + * + * Auth: + * - Bearer (default): a `gbrain auth create` token. Simple; long-lived + + * full-access. Best for local/personal use. + * - OAuth 2.1 client credentials (`--oauth`, perplexity/generic only): the + * correct path for anything exposed to a third-party cloud — least-privilege + * scopes + short-lived rotating access tokens. The connector is given an + * issuer URL + client_id + client_secret; it mints its own tokens. + * + * Per-agent shape: + * - claude-code: `claude mcp add ... -H "Authorization: Bearer "` (bearer + * only; --install runs it). + * - codex: `codex mcp add --url --bearer-token-env-var + * GBRAIN_REMOTE_TOKEN` (bearer via env var; --install runs it). + * - perplexity: GUI connector (Settings → Connectors). Supports bearer or + * OAuth; no --install. + * - generic: prints the connector fields for any other MCP client. + */ + +import { execFileSync } from 'child_process'; +import type { ConnectProbeResult } from '../core/connect-probe.ts'; +import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts'; +import { promptLine } from '../core/cli-util.ts'; + +export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN'; +export const PLACEHOLDER_TOKEN = ''; +export const PLACEHOLDER_SECRET = ''; +export const REDACTED = '***'; +export const DEFAULT_NAME = 'gbrain'; +export const DEFAULT_SCOPES = 'read write'; +const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/; +// Single source of truth shared with the probe (was a duplicated 15_000 literal). +const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS; + +export type AgentId = 'claude-code' | 'codex' | 'perplexity' | 'generic'; + +interface AgentSpec { + id: AgentId; + label: string; // human label for messages + binary?: string; // CLI binary backing --install ('claude' | 'codex') + installable: boolean; + supportsOAuth: boolean; // accepts OAuth client-credentials connector fields +} + +export const AGENT_SPECS: Record = { + 'claude-code': { id: 'claude-code', label: 'Claude Code', binary: 'claude', installable: true, supportsOAuth: false }, + codex: { id: 'codex', label: 'Codex', binary: 'codex', installable: true, supportsOAuth: false }, + perplexity: { id: 'perplexity', label: 'Perplexity Computer', installable: false, supportsOAuth: true }, + generic: { id: 'generic', label: 'your agent', installable: false, supportsOAuth: true }, +}; + +export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'perplexity', 'generic']; + +// The named tools MUST be real MCP-exposed ops (verified by the round-trip +// E2E). `capture` is intentionally absent: it's a CLI-only convenience wrapper, +// not an MCP tool — the agent writes over MCP with `put_page`. +export const LEARN_INSTRUCTION = + 'Once connected, call the `get_brain_identity` tool (whose brain this is), then ' + + '`list_skills` (everything it can do; if it errors, the host has not enabled skill ' + + 'publishing — these core tools still work: search, query, get_page, put_page, ' + + 'think, find_experts). Always search the brain before answering or writing.'; + +const SECRET_NOTE = + 'Note: that bearer token is a long-lived, full-access secret — keep it private and ' + + 'prefer a scoped/short-lived token if your host supports one.'; + +const OAUTH_SECRET_NOTE = + 'Note: the client secret is sensitive — store it like a password. It mints ' + + 'short-lived, scoped access tokens; revoke with `gbrain auth revoke-client`.'; + +const PERPLEXITY_REMOTE_NOTE = [ + 'Perplexity connects remotely, so the brain must be reachable over HTTPS. On the', + 'host run: gbrain serve --http --bind 0.0.0.0 --public-url (the', + 'default 127.0.0.1 bind refuses tunneled connections). See docs/mcp/PERPLEXITY.md.', +].join('\n'); + +const HELP = `gbrain connect — wire a coding agent to a remote gbrain over MCP + +Usage: + gbrain connect [--token ] [flags] + +Prints a copy-paste setup block for your agent, or wires it up directly with +--install (claude-code + codex only). The MCP URL is your remote +'gbrain serve --http' endpoint; a bare host is rejected — pass an explicit +https:// URL. + +Auth: + Bearer token (default) simple, long-lived, full-access — best local/personal + --oauth OAuth 2.1 client credentials (perplexity/generic): + least-privilege scopes + short-lived tokens — best for + anything exposed to a third-party cloud + +Flags: + --token Bearer token (else $${ENV_VAR}; from 'gbrain auth create') + --name MCP server name in the agent (default: ${DEFAULT_NAME}) + --agent claude-code (default) | codex | perplexity | generic + --oauth Use OAuth client credentials instead of a bearer token + --register With --oauth: mint a client on the host (gbrain auth register-client) + --client-id With --oauth: use an existing OAuth client id + --client-secret With --oauth: use an existing OAuth client secret + --scopes "" With --oauth --register: client scopes (default: "${DEFAULT_SCOPES}") + --install Run the agent's MCP-add command, then smoke-test the token + (claude-code + codex only) + --yes Skip the install confirmation prompt + --force On --install, replace an existing server of the same name + --json Emit machine-readable JSON (secret redacted) + --show-token With --json, include the literal token/secret (avoid in logs) + --timeout-ms Smoke-test timeout for --install (default: ${DEFAULT_TIMEOUT_MS}) + +Examples: + gbrain connect https://brain.example.com/mcp --token gbrain_xxx + gbrain connect https://brain.example.com:3131 --install --yes + gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent codex + gbrain connect https://brain.example.com/mcp --agent perplexity --oauth --register + gbrain connect https://brain.example.com/mcp --agent perplexity --oauth \\ + --client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx +`; + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested in test/connect.test.ts) +// --------------------------------------------------------------------------- + +export type UrlResult = + | { ok: true; url: string; warning?: string } + | { ok: false; error: string }; + +/** + * Block link-local / cloud-metadata addresses — the one class of host that is + * never a legitimate brain endpoint but IS a token-exfil target (e.g. the AWS/ + * GCP metadata service at 169.254.169.254). Deliberately does NOT block + * localhost or RFC1918/LAN ranges: self-hosted brains on a private network are + * a documented, supported topology (`gbrain serve --http --bind`). + */ +export function isLinkLocalOrMetadata(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true; // IPv4 link-local incl. cloud metadata + if (h.startsWith('fe80:')) return true; // IPv6 link-local + if (h === 'fd00:ec2::254') return true; // AWS IMDSv2 over IPv6 + // IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254 dotted, or ::ffff:a9fe:xxxx + // hex where a9fe == 169.254) must not slip past the dotted-IPv4 check. + const mapped = h.match(/^::ffff:(.+)$/); + if (mapped) { + if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(mapped[1])) return true; + if (mapped[1].startsWith('a9fe:')) return true; + } + return false; +} + +/** + * Normalize an MCP URL to a canonical `//` ending in /mcp. + * Explicit spec (not best-effort) — see plan D-codex findings. + */ +export function normalizeMcpUrl(input: string): UrlResult { + const raw = (input ?? '').trim(); + if (!raw) { + return { ok: false, error: 'Missing MCP URL. Usage: gbrain connect --token ' }; + } + // Require an explicit scheme. A bare `host:3131` parses as scheme `host:` + // under WHATWG URL, so reject anything without `://`. + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) { + const guess = raw.replace(/^\/+/, ''); + return { ok: false, error: `Add an explicit scheme, e.g. https://${guess} (a bare host:port is ambiguous).` }; + } + let u: URL; + try { + u = new URL(raw); + } catch { + return { ok: false, error: `Invalid URL: ${raw}` }; + } + const scheme = u.protocol.toLowerCase(); + if (scheme !== 'http:' && scheme !== 'https:') { + return { ok: false, error: `Only http(s) URLs are supported (got ${u.protocol}).` }; + } + if (u.username || u.password) { + return { ok: false, error: 'Remove credentials from the URL (user:pass@host is not supported); pass the token via --token.' }; + } + if (u.search) { + return { ok: false, error: 'Remove the query string from the MCP URL.' }; + } + if (isLinkLocalOrMetadata(u.hostname)) { + return { ok: false, error: `Refusing to target a link-local / cloud-metadata address (${u.hostname}). Point the MCP URL at the brain host's real address.` }; + } + const host = u.host; // host:port; hostname already lowercased by URL + const path = u.pathname; + const trimmed = path.replace(/\/+$/, ''); + const lower = trimmed.toLowerCase(); + let finalPath: string; + if (path === '' || path === '/') { + finalPath = '/mcp'; + } else if (lower === '/mcp') { + finalPath = '/mcp'; + } else { + return { + ok: false, + error: `Unexpected path '${path}'. Pass the full /mcp URL, e.g. ${scheme}//${host}${trimmed}/mcp`, + }; + } + const url = `${scheme}//${host}${finalPath}`; + const hn = u.hostname.toLowerCase(); + const isLocal = hn === 'localhost' || hn === '127.0.0.1' || hn === '::1' || hn === '[::1]'; + if (scheme === 'http:' && !isLocal) { + return { ok: true, url, warning: 'Warning: http:// sends your bearer token unencrypted. Use https:// unless this is localhost.' }; + } + return { ok: true, url }; +} + +/** The OAuth issuer is the server base — the /mcp endpoint's URL minus /mcp. */ +export function issuerFromMcpUrl(url: string): string { + return url.replace(/\/mcp$/, ''); +} + +export type TokenValidation = { ok: true } | { ok: false; error: string }; + +/** Reject empty/whitespace/control-char tokens (a newline is a header-injection vector). */ +export function validateToken(token: string): TokenValidation { + if (!token || !token.trim()) return { ok: false, error: 'Token is empty.' }; + if (/\s/.test(token)) return { ok: false, error: 'Token contains whitespace (space/tab/newline) — refusing (header-injection risk).' }; + if (/[\x00-\x1f\x7f]/.test(token)) return { ok: false, error: 'Token contains control characters — refusing (header-injection risk).' }; + return { ok: true }; +} + +export type TokenResolution = + | { kind: 'literal'; token: string } + | { kind: 'placeholder' } + | { kind: 'error'; error: string }; + +export function resolveToken(opts: { tokenFlag?: string | null; env?: string | null; mode: 'print' | 'install' }): TokenResolution { + const t = opts.tokenFlag ?? opts.env ?? null; + if (t != null && t !== '') { + const v = validateToken(t); + if (!v.ok) return { kind: 'error', error: v.error }; + return { kind: 'literal', token: t }; + } + if (opts.mode === 'print') return { kind: 'placeholder' }; + return { + kind: 'error', + error: `No token. Pass --token or set ${ENV_VAR}. Create one on the host with: gbrain auth create ""`, + }; +} + +export function isValidName(name: string): boolean { + return NAME_RE.test(name); +} + +export function buildClaudeMcpAddArgv(p: { name: string; url: string; headerToken: string }): string[] { + return ['mcp', 'add', p.name, '-t', 'http', p.url, '-H', `Authorization: Bearer ${p.headerToken}`]; +} + +/** Codex reads the bearer from an env var at runtime — the token is NOT in argv. */ +export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] { + return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar]; +} + +/** + * POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks, + * etc. in a token are inert literals when the block is pasted into a shell + * (double-quoting would still allow command substitution). + */ +function shellQuote(arg: string): string { + if (/^[A-Za-z0-9_.:/@-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +/** Render ` ` as a copy-pasteable, shell-safe command string. */ +export function cmdString(binary: string, argv: string[]): string { + return `${binary} ${argv.map(shellQuote).join(' ')}`; +} + +export function redactToken(s: string, token: string | null): string { + // Exact-substring scrub of the known token, plus a defense-in-depth pass over + // any `Bearer ` shape the SDK/CLI might echo in a transformed form the + // exact match would miss. Both run on the --install error paths only. + let out = token ? s.split(token).join(REDACTED) : s; + out = out.replace(/Bearer\s+\S+/gi, `Bearer ${REDACTED}`); + return out; +} + +export interface OAuthCreds { + issuer: string; + clientId: string; + clientSecret: string | null; +} + +function claudeBlock(p: { name: string; url: string; token: string | null }): string { + const headerToken = p.token ?? PLACEHOLDER_TOKEN; + const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken })); + const lines = ['# Paste into Claude Code:', '', 'Connect my knowledge brain, then learn what it can do:', '', ` ${cmd}`, '']; + if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "claude-code"\` on the host.`, ''); + lines.push(LEARN_INSTRUCTION, '', SECRET_NOTE); + return lines.join('\n'); +} + +function codexBlock(p: { name: string; url: string; token: string | null }): string { + const tokenValue = p.token ?? PLACEHOLDER_TOKEN; + const cmd = cmdString('codex', buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR })); + const lines = [ + '# Paste into Codex:', + '', + 'Connect my knowledge brain, then learn what it can do:', + '', + ` export ${ENV_VAR}=${shellQuote(tokenValue)}`, + ` ${cmd}`, + '', + ]; + if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "codex"\` on the host.`, ''); + lines.push( + `Codex reads the token from $${ENV_VAR} at runtime — keep that variable set in your shell profile so new Codex sessions can reach the brain.`, + '', + LEARN_INSTRUCTION, + '', + SECRET_NOTE, + ); + return lines.join('\n'); +} + +function perplexityBearerBlock(p: { url: string; token: string | null }): string { + const tokenValue = p.token ?? PLACEHOLDER_TOKEN; + return [ + '# In Perplexity (Pro): Settings → Connectors → add a remote MCP server:', + `# URL: ${p.url}`, + '# Auth: Bearer token (API key)', + `# Token: ${tokenValue}`, + '', + PERPLEXITY_REMOTE_NOTE, + '', + LEARN_INSTRUCTION, + '', + SECRET_NOTE, + ].join('\n'); +} + +function perplexityOAuthBlock(p: { oauth: OAuthCreds }): string { + const secret = p.oauth.clientSecret ?? PLACEHOLDER_SECRET; + return [ + '# In Perplexity (Pro): Settings → Connectors → add a remote MCP server:', + `# URL: ${p.oauth.issuer}/mcp`, + '# Auth: OAuth 2.1 (client credentials)', + `# Issuer URL: ${p.oauth.issuer}`, + `# Client ID: ${p.oauth.clientId}`, + `# Client Secret: ${secret}`, + '', + 'OAuth is the recommended path for Perplexity (a cloud service): the connector', + 'mints short-lived, scoped access tokens instead of holding a long-lived secret.', + '', + PERPLEXITY_REMOTE_NOTE, + '', + LEARN_INSTRUCTION, + '', + OAUTH_SECRET_NOTE, + ].join('\n'); +} + +function genericBearerBlock(p: { url: string; token: string | null }): string { + const headerToken = p.token ?? PLACEHOLDER_TOKEN; + return [ + '# Add an HTTP MCP server pointed at your gbrain:', + `# URL: ${p.url}`, + `# Header: Authorization: Bearer ${headerToken}`, + '', + LEARN_INSTRUCTION, + ].join('\n'); +} + +function genericOAuthBlock(p: { oauth: OAuthCreds }): string { + const secret = p.oauth.clientSecret ?? PLACEHOLDER_SECRET; + return [ + '# Add an OAuth 2.1 (client-credentials) MCP server pointed at your gbrain:', + `# URL: ${p.oauth.issuer}/mcp`, + `# Issuer URL: ${p.oauth.issuer}`, + `# Client ID: ${p.oauth.clientId}`, + `# Client Secret: ${secret}`, + '', + LEARN_INSTRUCTION, + '', + OAUTH_SECRET_NOTE, + ].join('\n'); +} + +export function buildConnectBlock(p: { agent: AgentId; name: string; url: string; token: string | null; oauth?: OAuthCreds }): string { + if (p.oauth) { + // OAuth is only emitted for connector-style agents (gated upstream). + return p.agent === 'generic' ? genericOAuthBlock({ oauth: p.oauth }) : perplexityOAuthBlock({ oauth: p.oauth }); + } + switch (p.agent) { + case 'claude-code': return claudeBlock(p); + case 'codex': return codexBlock(p); + case 'perplexity': return perplexityBearerBlock(p); + case 'generic': return genericBearerBlock(p); + } +} + +export function buildJson(p: { url: string; name: string; agent: AgentId; token: string | null; showToken: boolean; oauth?: OAuthCreds; scopes?: string }): Record { + if (p.oauth) { + const secret = p.oauth.clientSecret; + return { + schema_version: 1, + agent: p.agent, + mcp_url: p.url, + name: p.name, + auth: 'oauth', + issuer_url: p.oauth.issuer, + client_id: p.oauth.clientId, + client_secret: secret == null ? null : (p.showToken ? secret : REDACTED), + secret_redacted: secret != null && !p.showToken, + scopes: p.scopes ?? DEFAULT_SCOPES, + command: null, + command_argv: null, + learn_instruction: LEARN_INSTRUCTION, + }; + } + const shownToken = p.token ? (p.showToken ? p.token : REDACTED) : PLACEHOLDER_TOKEN; + let command_argv: string[] | null = null; + let command: string | null = null; + if (p.agent === 'claude-code') { + command_argv = buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken: shownToken }); + command = cmdString('claude', command_argv); + } else if (p.agent === 'codex') { + // Codex command carries no token (env-var name only), so it's safe verbatim. + command_argv = buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR }); + command = cmdString('codex', command_argv); + } + return { + schema_version: 1, + agent: p.agent, + mcp_url: p.url, + name: p.name, + auth: 'bearer', + env_var: ENV_VAR, + token_present: p.token != null, + token_redacted: p.token != null && !p.showToken, + header: `Authorization: Bearer ${shownToken}`, + command, // runnable CLI command; null for perplexity/generic (UI/manual setup) + command_argv, + learn_instruction: LEARN_INSTRUCTION, + }; +} + +// --------------------------------------------------------------------------- +// --install / --register dependencies (injectable for tests) +// --------------------------------------------------------------------------- + +export type RegisterResult = + | { ok: true; clientId: string; clientSecret: string } + | { ok: false; message: string }; + +export interface ConnectDeps { + isTTY(): boolean; + promptYesNo(question: string): Promise; + hasBinary(binary: string): boolean; + runBinary(binary: string, argv: string[]): { code: number; stdout: string; stderr: string }; + probe(url: string, token: string, timeoutMs: number): Promise; + env(name: string): string | undefined; + registerOAuthClient(name: string, scopes: string): RegisterResult; +} + +async function defaultPromptYesNo(question: string): Promise { + // Reuse the shared prompt helper so stdin pause/resume lifecycle matches the + // rest of the interactive CLI flows (init, apply-migrations, ...). + const answer = (await promptLine(`${question} (y/N): `)).toLowerCase(); + return answer === 'y' || answer === 'yes'; +} + +function defaultRunBinary(binary: string, argv: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(binary, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return { code: 0, stdout: stdout ?? '', stderr: '' }; + } catch (e) { + const err = e as { status?: number; stdout?: string | Buffer; stderr?: string | Buffer; message?: string }; + return { + code: typeof err.status === 'number' ? err.status : 1, + stdout: err.stdout ? String(err.stdout) : '', + stderr: err.stderr ? String(err.stderr) : (err.message ?? ''), + }; + } +} + +/** Mint an OAuth client by shelling to the host's `gbrain auth register-client`. */ +function defaultRegisterOAuthClient(name: string, scopes: string): RegisterResult { + const r = defaultRunBinary('gbrain', [ + 'auth', 'register-client', name, + '--grant-types', 'client_credentials', + '--scopes', scopes, + '--token-endpoint-auth-method', 'client_secret_post', + ]); + if (r.code !== 0) { + return { ok: false, message: r.stderr || r.stdout || 'gbrain auth register-client failed' }; + } + const clientId = r.stdout.match(/Client ID:\s+(\S+)/)?.[1]; + const clientSecret = r.stdout.match(/Client Secret:\s+(\S+)/)?.[1]; + if (!clientId || !clientSecret) { + return { ok: false, message: 'could not parse client_id/client_secret from register-client output' }; + } + return { ok: true, clientId, clientSecret }; +} + +const defaultDeps: ConnectDeps = { + isTTY: () => !!process.stdin.isTTY, + promptYesNo: defaultPromptYesNo, + hasBinary: (binary) => { + try { + execFileSync(binary, ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }, + runBinary: defaultRunBinary, + probe: (url, token, timeoutMs) => probeBrainIdentity(url, token, { timeoutMs }), + env: (name) => process.env[name], + registerOAuthClient: defaultRegisterOAuthClient, +}; + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +interface ParsedFlags { + url?: string; + token?: string; + name: string; + agent: AgentId; + oauth: boolean; + register: boolean; + clientId?: string; + clientSecret?: string; + scopes: string; + install: boolean; + yes: boolean; + force: boolean; + json: boolean; + showToken: boolean; + timeoutMs: number; + help: boolean; + agentError?: string; + argError?: string; +} + +function parseArgs(args: string[]): ParsedFlags { + const out: ParsedFlags = { + name: DEFAULT_NAME, + agent: 'claude-code', + oauth: false, + register: false, + scopes: DEFAULT_SCOPES, + install: false, + yes: false, + force: false, + json: false, + showToken: false, + timeoutMs: DEFAULT_TIMEOUT_MS, + help: false, + }; + // Read the value for a value-taking flag, refusing a missing value or one + // that is itself a flag (e.g. `--token --install` would otherwise silently + // consume `--install` as the token and leave install off). Shares `i` with + // the loop below, so it is declared in the function body, not the for-header. + let i = 0; + const takeValue = (flag: string): string | undefined => { + const v = args[i + 1]; + if (v === undefined || v.startsWith('--')) { + out.argError = `${flag} requires a value.`; + return undefined; + } + i++; + return v; + }; + for (; i < args.length; i++) { + const a = args[i]; + switch (a) { + case '--help': case '-h': out.help = true; break; + case '--install': out.install = true; break; + case '--oauth': out.oauth = true; break; + case '--register': out.register = true; break; + case '--yes': case '-y': out.yes = true; break; + case '--force': out.force = true; break; + case '--json': out.json = true; break; + case '--show-token': out.showToken = true; break; + case '--token': { const v = takeValue('--token'); if (v !== undefined) out.token = v; break; } + case '--client-id': { const v = takeValue('--client-id'); if (v !== undefined) out.clientId = v; break; } + case '--client-secret': { const v = takeValue('--client-secret'); if (v !== undefined) out.clientSecret = v; break; } + case '--scopes': { const v = takeValue('--scopes'); if (v !== undefined) out.scopes = v; break; } + case '--name': { const v = takeValue('--name'); if (v !== undefined) out.name = v; break; } + case '--agent': { + const v = takeValue('--agent'); + if (v === undefined) break; + if ((AGENT_IDS as string[]).includes(v)) out.agent = v as AgentId; + else out.agentError = `Unknown --agent '${v}'. Use one of: ${AGENT_IDS.join(', ')}.`; + break; + } + case '--timeout-ms': { + const raw = takeValue('--timeout-ms'); + if (raw === undefined) break; + const n = parseInt(raw, 10); + if (Number.isFinite(n) && n > 0) out.timeoutMs = n; + break; + } + default: + if (!a.startsWith('-') && out.url === undefined) out.url = a; + break; + } + } + return out; +} + +function fail(msg: string): never { + console.error(msg); + process.exit(1); +} + +/** Resolve OAuth creds from explicit flags or by registering a client on the host. */ +function resolveOAuthCreds(f: ParsedFlags, url: string, deps: ConnectDeps): OAuthCreds { + const issuer = issuerFromMcpUrl(url); + if (f.clientId && f.clientSecret) { + return { issuer, clientId: f.clientId, clientSecret: f.clientSecret }; + } + if (f.clientId || f.clientSecret) { + fail('--oauth needs BOTH --client-id and --client-secret (or use --register to mint a client).'); + } + if (f.register) { + const r = deps.registerOAuthClient(f.name, f.scopes); + if (!r.ok) { + fail(`Could not register an OAuth client (run this on the brain host where the DB lives): ${r.message}\n` + + `Or mint one manually: gbrain auth register-client ${f.name} --grant-types client_credentials --scopes "${f.scopes}"`); + } + return { issuer, clientId: r.clientId, clientSecret: r.clientSecret }; + } + return fail( + '--oauth needs an OAuth client. Either:\n' + + ` • --register (mint one on the host: gbrain auth register-client ${f.name} --grant-types client_credentials --scopes "${f.scopes}")\n` + + ' • --client-id --client-secret (use an existing client)', + ); +} + +export async function runConnect(args: string[], deps: ConnectDeps = defaultDeps): Promise { + const f = parseArgs(args); + if (f.help) { + console.log(HELP); + return; + } + if (f.argError) fail(f.argError); + if (f.agentError) fail(f.agentError); + if (!isValidName(f.name)) { + fail(`Invalid --name '${f.name}'. Use a lowercase identifier matching ${NAME_RE}.`); + } + + const norm = normalizeMcpUrl(f.url ?? ''); + if (!norm.ok) fail(norm.error); + if (norm.warning) console.error(norm.warning); + const url = norm.url; + const spec = AGENT_SPECS[f.agent]; + + // ---- OAuth path (connector-style agents only; no --install) ---- + if (f.oauth) { + if (!spec.supportsOAuth) { + fail(`--oauth (client credentials) is for connector-style agents (${AGENT_IDS.filter((a) => AGENT_SPECS[a].supportsOAuth).join(', ')}). ${spec.label} uses the bearer path — drop --oauth.`); + } + if (f.install) { + fail(`--install is not supported with --oauth. ${spec.label} is configured through its UI; this prints the OAuth connector fields to paste.`); + } + const oauth = resolveOAuthCreds(f, url, deps); + if (f.json) { + console.log(JSON.stringify(buildJson({ url, name: f.name, agent: f.agent, token: null, showToken: f.showToken, oauth, scopes: f.scopes }), null, 2)); + } else { + console.log(buildConnectBlock({ agent: f.agent, name: f.name, url, token: null, oauth })); + } + return; + } + + const mode = f.install ? 'install' : 'print'; + const tok = resolveToken({ tokenFlag: f.token ?? null, env: deps.env(ENV_VAR) ?? null, mode }); + if (tok.kind === 'error') fail(tok.error); + const token: string | null = tok.kind === 'literal' ? tok.token : null; + + if (!f.install) { + if (f.json) { + console.log(JSON.stringify(buildJson({ url, name: f.name, agent: f.agent, token, showToken: f.showToken }), null, 2)); + } else { + console.log(buildConnectBlock({ agent: f.agent, name: f.name, url, token })); + } + return; + } + + // --install path. token is guaranteed literal here (install mode resolveToken). + const realToken = token as string; + if (!spec.installable) { + fail(`--install supports claude-code and codex. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`); + } + const binary = spec.binary as string; // 'claude' | 'codex' + if (!deps.hasBinary(binary)) { + fail(`${spec.label} CLI ('${binary}') not found on PATH. Install ${spec.label}, or drop --install to print the command to run manually.`); + } + + const exists = deps.runBinary(binary, ['mcp', 'get', f.name]).code === 0; + if (exists && !f.force) { + fail(`An MCP server named '${f.name}' already exists in ${spec.label}. Run '${binary} mcp remove ${f.name}' first, pass --name , or --force to replace it.`); + } + + if (!f.yes) { + if (!deps.isTTY()) { + // Non-interactive --install registers a credential-bearing MCP server and + // fires the token at a remote host — require an explicit --yes rather than + // silently proceeding when there's no TTY to confirm at. + fail('--install in a non-interactive shell requires --yes (refusing to register a credential-bearing MCP server without confirmation).'); + } + const ok = await deps.promptYesNo(`Add MCP server '${f.name}' -> ${url} to ${spec.label}?`); + if (!ok) fail('Aborted.'); + } + + let removedExisting = false; + if (exists && f.force) { + const rm = deps.runBinary(binary, ['mcp', 'remove', f.name]); + if (rm.code !== 0) { + fail(`Could not replace existing server '${f.name}': ${redactToken(rm.stderr || rm.stdout, realToken)}`); + } + removedExisting = true; + } + + const addArgv = f.agent === 'codex' + ? buildCodexMcpAddArgv({ name: f.name, url, envVar: ENV_VAR }) + : buildClaudeMcpAddArgv({ name: f.name, url, headerToken: realToken }); + const add = deps.runBinary(binary, addArgv); + if (add.code !== 0) { + const note = removedExisting ? ` (note: the previous '${f.name}' was already removed — re-run to restore it)` : ''; + fail(`'${binary} mcp add' failed${note}: ${redactToken(add.stderr || add.stdout, realToken)}`); + } + console.error(`Added MCP server '${f.name}' -> ${url}.`); + + // Codex reads the token from the env var at runtime, not from its config. + // If the current env doesn't already carry it, the user must export it. + if (f.agent === 'codex' && deps.env(ENV_VAR) !== realToken) { + console.error(`Codex reads the token from $${ENV_VAR} at runtime. Add this to your shell profile so new sessions can reach the brain:`); + console.error(` export ${ENV_VAR}=`); + } + + // D4 smoke-test: prove the token actually authenticates a tool call now, + // instead of failing silently on the agent's first request. + const probe = await deps.probe(url, realToken, f.timeoutMs); + if (probe.ok) { + console.error(`Verified: ${probe.identity || 'brain reachable'}`); + console.error(''); + console.error(LEARN_INSTRUCTION); + return; + } + // Server is registered, but end-to-end auth did not verify. Exit non-zero so + // scripts notice; the message never echoes the token. + console.error( + `Warning: registered '${f.name}', but the smoke-test did not verify (${probe.reason}): ${redactToken(probe.message, realToken)}`, + ); + console.error('The agent will likely hit 401/errors until the token or URL is fixed.'); + process.exit(1); +} diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index f77271df5..7c8f3e32f 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -374,6 +374,27 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise Promise<{ isError?: boolean; content?: unknown }>; +} + +/** + * Map a thrown error message to a probe reason. Pure + exported so the + * classification is unit-testable without a network. + */ +export function classifyProbeError(message: string): ConnectProbeReason { + if (/timeout|abort/i.test(message)) return 'timeout'; + if (isAuthErrorMessage(message)) return 'auth'; + // undici/fetch + MCP SDK transport failures: DNS, ECONNREFUSED, TLS, + // getaddrinfo, and the SDK's friendly "Unable to connect..." wrapper. + if (/fetch failed|unable to connect|connection refused|failed to connect|could not connect|ENOTFOUND|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ETIMEDOUT|network|socket|tls|certificate/i.test(message)) { + return 'unreachable'; + } + return 'unknown'; +} + +/** Pull the text payload out of an MCP tool result's content array. */ +export function extractResultText(content: unknown): string { + if (!Array.isArray(content)) return ''; + return content + .map((c) => (c && typeof c === 'object' && typeof (c as { text?: unknown }).text === 'string' + ? (c as { text: string }).text + : '')) + .filter(Boolean) + .join('\n'); +} + +const DEFAULT_DEPS: ProbeDeps = { + connectAndCall: async (mcpUrl, token, signal) => { + const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { + requestInit: { + headers: { Authorization: `Bearer ${token}` }, + signal, + }, + }); + const client = new Client( + { name: 'gbrain-connect-probe', version: '1' }, + { capabilities: {} }, + ); + // close() lives in a finally that wraps connect() too — if connect() + // throws mid-handshake the transport/socket must still be torn down. + try { + await client.connect(transport); + // callTool's return is a wide union (incl. the legacy {toolResult} + // shape); we only read isError + content, so narrow at the boundary. + const res = await client.callTool({ name: 'get_brain_identity', arguments: {} }); + return res as { isError?: boolean; content?: unknown }; + } finally { + try { await client.close(); } catch { /* best-effort */ } + } + }, +}; + +/** + * Probe `` with the bearer token by calling `get_brain_identity`. + * `timeoutMs` defaults to 15s (the smokeTestMcp default). Pass `deps` to stub + * the round-trip in unit tests. + */ +export async function probeBrainIdentity( + mcpUrl: string, + token: string, + opts: { timeoutMs?: number; deps?: ProbeDeps } = {}, +): Promise { + const deps = opts.deps ?? DEFAULT_DEPS; + const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const controller = new AbortController(); + let timer: ReturnType | undefined; + // Promise.race against a real timer: the AbortSignal alone does NOT cover + // client.connect()'s initialize/SSE handshake, so a server that accepts the + // socket then stalls before responding would hang the probe (and the whole + // --install) forever. The race makes the timeout guarantee actually hold — + // the abandoned connectAndCall promise settles in the background; the CLI + // exits regardless. + const timeoutGuard = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(new Error('timeout')); + // Message must contain "timeout" so classifyProbeError maps it correctly. + reject(new Error(`probe timeout after ${timeoutMs}ms`)); + }, timeoutMs); + }); + try { + const res = await Promise.race([deps.connectAndCall(mcpUrl, token, controller.signal), timeoutGuard]); + if (res.isError) { + const message = extractResultText(res.content) || 'unknown tool error'; + const reason = isAuthErrorMessage(message) ? 'auth' : 'tool_error'; + return { ok: false, reason, message }; + } + const identity = extractResultText(res.content); + return { ok: true, identity }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { ok: false, reason: classifyProbeError(message), message }; + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/test/audit/batch-retry-audit.test.ts b/test/audit/batch-retry-audit.test.ts index 8a93ef8bd..9fcae9c5b 100644 --- a/test/audit/batch-retry-audit.test.ts +++ b/test/audit/batch-retry-audit.test.ts @@ -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 }); + }); }); }); diff --git a/test/auth-create-args.test.ts b/test/auth-create-args.test.ts new file mode 100644 index 000000000..3ce9e7758 --- /dev/null +++ b/test/auth-create-args.test.ts @@ -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']); + }); +}); diff --git a/test/connect.test.ts b/test/connect.test.ts new file mode 100644 index 000000000..ce25d3da5 --- /dev/null +++ b/test/connect.test.ts @@ -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 { + 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/); + }); +}); diff --git a/test/e2e/connect-bearer.test.ts b/test/e2e/connect-bearer.test.ts new file mode 100644 index 000000000..cf8aac313 --- /dev/null +++ b/test/e2e/connect-bearer.test.ts @@ -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 ` 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): { 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); +}); diff --git a/test/e2e/serve-stdio-roundtrip.test.ts b/test/e2e/serve-stdio-roundtrip.test.ts new file mode 100644 index 000000000..ec0911707 --- /dev/null +++ b/test/e2e/serve-stdio-roundtrip.test.ts @@ -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); +}); diff --git a/test/serve-skills-publish-nudge.test.ts b/test/serve-skills-publish-nudge.test.ts new file mode 100644 index 000000000..51c57ed01 --- /dev/null +++ b/test/serve-skills-publish-nudge.test.ts @@ -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); + }); +});