From eb832b31ab87e26403d80025776a1372260d0c10 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Tue, 28 Jul 2026 12:00:09 +0300 Subject: [PATCH 1/3] fix(brains): let Codex own the MCP credential via its own OAuth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex plugin declared `bearer_token_env_var`, which Codex resolves from its own process environment when it starts the MCP server. Nothing in a Codex plugin install can set that variable — the plugin manifest has no field for a secret and `plugin add` never prompts — so a plugin-only install could not authenticate, and `codex mcp list` still reported `Bearer token` because it reports on the presence of the config key rather than the value. Its presence also switches Codex's OAuth path off entirely, so the two could not coexist. Drop the declaration and let Codex authenticate itself: `codex mcp login brains` runs a browser consent flow, registers dynamically, and stores the token in the OS keychain. `scopes` is baked rather than discovered — with no `scopes` key Codex requests every scope the server advertises, which would mint an admin-carrying token for every user. Conversation capture and the inbox keep their own bearer and become opt-in: the MCP server no longer depends on it, so a user who never sets a token still gets a working plugin. Both hooks already no-op silently in that state; the contract test now pins it. Marketplace auth policy becomes ON_USE, which is what actually happens. The contract test gains an allow-list on the server declaration's keys and a resolved-config check that asks a real Codex what it made of the file. Codex ignores keys it does not recognise instead of rejecting them, so a misspelled `scopes` would otherwise pass review and silently widen every user's token. --- .agents/plugins/marketplace.json | 2 +- plugins/brains/.claude-plugin/plugin.json | 2 +- plugins/brains/.codex-plugin/plugin.json | 2 +- plugins/brains/.mcp.json | 2 +- tests/plugin-contract/run.ts | 122 +++++++++++++++++++++- 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 9a2170b..45d968d 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -12,7 +12,7 @@ }, "policy": { "installation": "AVAILABLE", - "authentication": "ON_INSTALL" + "authentication": "ON_USE" }, "category": "Productivity" } diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index 4d19911..99d789c 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "brains", "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", - "version": "2.3.3", + "version": "2.4.0", "author": { "name": "brains (ssvlabs)" }, diff --git a/plugins/brains/.codex-plugin/plugin.json b/plugins/brains/.codex-plugin/plugin.json index 1357b77..0ebe00c 100644 --- a/plugins/brains/.codex-plugin/plugin.json +++ b/plugins/brains/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "version": "2.3.3", + "version": "2.4.0", "description": "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, then build boards, automations, and workflows.", "author": { "name": "brains (ssvlabs)", diff --git a/plugins/brains/.mcp.json b/plugins/brains/.mcp.json index 8ec7a70..87e6e23 100644 --- a/plugins/brains/.mcp.json +++ b/plugins/brains/.mcp.json @@ -3,7 +3,7 @@ "brains": { "type": "http", "url": "https://mcp.mybrains.ai/mcp", - "bearer_token_env_var": "BRAINS_API_TOKEN" + "scopes": ["read", "write"] } } } diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index d4b0301..51f3eb5 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -8,6 +8,14 @@ import { join, resolve } from "node:path"; const ROOT = resolve(import.meta.dir, "..", ".."); const PLUGIN = join(ROOT, "plugins", "brains"); +// The scope set the Codex MCP server requests at login, and the complete set of keys its +// declaration may carry. Both are pinned because Codex IGNORES keys it does not recognise rather +// than rejecting them: a misspelled `scope` would be dropped silently and the login would fall +// back to requesting every scope the server advertises. Codex does not echo `scopes` back through +// `mcp list` / `mcp get`, so an allow-list on this side is the only defence against a typo. +const CODEX_MCP_SCOPES = ["read", "write"]; +const CODEX_MCP_KEYS = ["type", "url", "scopes"]; + class AssertionError extends Error {} function readJson(path: string): any { @@ -58,7 +66,9 @@ assert(!("hooks" in codexManifest), "Codex should discover the default hooks/hoo assert(claudeMarketplace.plugins[0]?.source === "./plugins/brains", "Claude marketplace source mismatch"); assert(codexMarketplace.plugins[0]?.source?.path === "./plugins/brains", "Codex marketplace source mismatch"); assert(codexMarketplace.plugins[0]?.policy?.installation === "AVAILABLE", "Codex install policy missing"); -assert(codexMarketplace.plugins[0]?.policy?.authentication === "ON_INSTALL", "Codex auth policy missing"); +// Codex authenticates the MCP server on first use (`codex mcp login brains`), not during +// `plugin add` — its plugin manifest has no field that could carry a credential at install time. +assert(codexMarketplace.plugins[0]?.policy?.authentication === "ON_USE", "Codex auth policy must be ON_USE"); const claudeEvents = Object.keys(claudeHooks.hooks).sort(); const codexEvents = Object.keys(codexHooks.hooks).sort(); @@ -88,7 +98,73 @@ for (const command of hookScripts(codexHooks)) { assert(codexMcp.mcpServers?.brains?.type === "http", "Codex brains MCP must be HTTP"); assert(codexMcp.mcpServers?.brains?.url === "https://mcp.mybrains.ai/mcp", "Codex brains MCP URL mismatch"); -assert(codexMcp.mcpServers?.brains?.bearer_token_env_var === "BRAINS_API_TOKEN", "Codex token env mismatch"); + +// Codex owns the MCP credential via its own OAuth login. `bearer_token_env_var` must stay ABSENT: +// Codex reads that variable from its own process environment at server start and fails hard when +// it is missing, and its presence also switches the OAuth path off entirely — so declaring it +// would both break a plugin install (nothing can set the variable) and block `codex mcp login`. +assert( + !("bearer_token_env_var" in (codexMcp.mcpServers?.brains ?? {})), + "Codex brains MCP must not declare bearer_token_env_var — it disables the OAuth login path", +); +// The scope set requested at login. Baked rather than discovered: with no `scopes` key Codex asks +// for every scope the server advertises, which would mint an admin-carrying token for every user. +assert( + JSON.stringify(codexMcp.mcpServers?.brains?.scopes) === JSON.stringify(CODEX_MCP_SCOPES), + `Codex brains MCP scopes must be ${JSON.stringify(CODEX_MCP_SCOPES)}`, +); +assert( + JSON.stringify(Object.keys(codexMcp.mcpServers?.brains ?? {}).sort()) + === JSON.stringify([...CODEX_MCP_KEYS].sort()), + `Codex brains MCP may only declare ${CODEX_MCP_KEYS.join(", ")} — got ${Object.keys(codexMcp.mcpServers?.brains ?? {}).join(", ")}`, +); + +// Ask a real Codex what it made of the declaration, rather than trusting that the file we wrote is +// the config Codex resolved. This is what catches an upstream change that stops honouring the +// plugin MCP shape: the keys above could all be correct and the server still fail to resolve, or +// resolve onto the bearer path. Skipped with a visible note when Codex is not installed. +const codexOnPath = spawnSync("codex", ["--version"], { encoding: "utf8" }).status === 0; +if (!codexOnPath) { + console.log("plugin contract: SKIP resolved-config check (codex not on PATH)"); +} else { + const home = mkdtempSync(join(tmpdir(), "brains-plugin-contract-codex-")); + try { + const codex = (args: string[]) => + spawnSync("codex", args, { encoding: "utf8", env: { ...process.env, CODEX_HOME: home } }); + const added = codex(["plugin", "marketplace", "add", ROOT]); + assert(added.status === 0, `codex plugin marketplace add failed: ${added.stderr}`); + const installed = codex(["plugin", "add", "brains@brains"]); + assert(installed.status === 0, `codex plugin add failed: ${installed.stderr}`); + + const listed = codex(["mcp", "list", "--json"]); + assert(listed.status === 0, `codex mcp list --json failed: ${listed.stderr}`); + const servers = JSON.parse(listed.stdout); + const resolved = servers.find((s: any) => s.name === "brains"); + assert(resolved, "codex did not resolve a `brains` MCP server from the plugin declaration"); + assert(resolved.enabled === true, "resolved brains MCP server must be enabled"); + assert( + resolved.transport?.type === "streamable_http", + `resolved transport must be streamable_http, got ${resolved.transport?.type}`, + ); + assert( + resolved.transport?.url === codexMcp.mcpServers.brains.url, + "resolved transport URL must match the declaration", + ); + assert( + resolved.transport?.bearer_token_env_var === null, + "resolved transport must carry no bearer token env var", + ); + // Not logged in yet, and Codex says so rather than claiming a credential it does not have — + // which is the whole point of dropping the bearer declaration. + assert( + resolved.auth_status === "not_logged_in", + `resolved auth status must be not_logged_in, got ${resolved.auth_status}`, + ); + console.log("plugin contract: resolved-config check OK (codex resolved the plugin declaration)"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} assert(core.includes(""), "core marker must be v5"); for (const signal of [ @@ -242,6 +318,48 @@ try { assert(assistantPayloads[0].role === "assistant", "Codex Stop payload must use the assistant role"); assert(assistantPayloads[0].client === "codex", "Codex Stop payload must identify Codex"); assert(assistantPayloads[0].content === "hello from codex", "Codex Stop payload must preserve the response"); + + // Conversation capture and the inbox are OPT-IN: the MCP server authenticates itself, so a user + // who never sets a token still gets a working plugin. Both hooks must therefore no-op silently + // when no credential resolves — exit 0, no output, no request — rather than erroring or hanging. + // `codexless` shadows `codex` with a failing stub so the turn hook's config scavenge finds + // nothing, which is the state of a plugin install that has only ever done `codex mcp login`. + const codexless = join(temp, "codexless"); + mkdirSync(codexless); + writeFileSync(join(codexless, "codex"), "#!/bin/sh\nexit 1\n"); + chmodSync(join(codexless, "codex"), 0o755); + const noTokenCapture = join(temp, "no-token-payloads.jsonl"); + const noTokenEnv = { + ...process.env, + PATH: `${codexless}:${bin}:${process.env.PATH ?? ""}`, + PLUGIN_ROOT: PLUGIN, + BRAINS_API_TOKEN: "", + BRAINS_INBOX_TOKEN: "", + CLAUDE_PLUGIN_OPTION_TOKEN: "", + BRAINS_STATE_DIR: join(temp, "state-no-token"), + CAPTURE_FILE: noTokenCapture, + }; + const turnNoToken = spawnSync("bash", [join(PLUGIN, "hooks", "brains-turn.sh")], { + input: JSON.stringify({ session_id: "codex-session", prompt: "hello" }), + env: noTokenEnv, + }); + assert(turnNoToken.status === 0, "turn hook must exit 0 with no token available"); + assert( + turnNoToken.stdout.toString() === "" && turnNoToken.stderr.toString() === "", + "turn hook must stay silent with no token available", + ); + assert(!existsSync(noTokenCapture), "turn hook must not POST anything with no token available"); + + const inboxNoToken = spawnSync( + "bash", + [join(PLUGIN, "hooks", "lib", "brains-inbox.sh"), "startup", "codex-session"], + { env: noTokenEnv }, + ); + assert(inboxNoToken.status === 0, "inbox engine must exit 0 with no token available"); + assert( + inboxNoToken.stdout.toString() === "" && inboxNoToken.stderr.toString() === "", + "inbox engine must stay silent with no token available", + ); } finally { rmSync(temp, { recursive: true, force: true }); } From ac41412115ad55f1708e6283ca0981925b600ea2 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Tue, 28 Jul 2026 13:41:51 +0300 Subject: [PATCH 2/3] docs(brains): bring the Codex README onto the sign-in flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README still documented `export BRAINS_API_TOKEN` as the way into Codex, with `launchctl setenv` for the desktop app. That install cannot work: Codex reads the variable from its own process environment when it starts the MCP server, and nothing in a plugin install can set it. Anyone who finds this repo directly — rather than the guided page — was following instructions that end in "not set" at startup. Rewrite it around `codex mcp login brains`, with the version check first and the consent-screen expectation spelled out. The token keeps a section of its own, under an explicit "optional" heading, as the credential for conversation capture and the inbox; the desktop `launchctl` path moves there with it, since a desktop app inherits no shell export and would otherwise silently get no capture. The version floor is 0.131 and the probe is `codex plugin --help`: `codex mcp login` shipped long before the `codex plugin` commands, so probing the login passes on versions that then fail at `marketplace add`. The contract test now pins the README the way it pins the declaration — the sign-in command, the floor, the probe, the desktop path, and that no token is presented above the optional heading. It also guards the shape of the MCP file rather than just the keys inside our entry: exactly one top-level key, exactly one server. A sibling entry — a stdio `command` server especially, which Codex would launch on the user's machine — previously passed every assertion, and the resolved-config check selected our server out of the list instead of rejecting the extra one. --- README.md | 53 ++++++++++++++++++++++++---- tests/plugin-contract/run.ts | 67 ++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e08f682..9319e03 100644 --- a/README.md +++ b/README.md @@ -10,30 +10,71 @@ authentication declarations are client-specific. ## Install for Codex -Find your brains API token in your brains account settings, then make it -available to Codex as `BRAINS_API_TOKEN`. +No token needed — Codex signs itself in. + +Needs Codex **0.131** or newer. Check with: + +```sh +codex plugin --help +``` + +If that errors with an unknown subcommand, run `codex update` first. ```sh -export BRAINS_API_TOKEN="" codex plugin marketplace add ssvlabs/brains-plugins codex plugin add brains@brains +codex mcp login brains ``` -The `export` applies to Codex started from that shell. If you use the macOS -desktop app, set the variable for the app's launch environment (for example, -`launchctl setenv BRAINS_API_TOKEN ""`) before restarting it. +`codex mcp login brains` opens your browser to approve the connection. The +approval screen says **An app on this computer** and shows a `127.0.0.1` address +whose port changes every time — that is Codex waiting on your machine, and it is +expected. Codex stores the credential itself, so there is nothing to copy or +keep. Confirm with `codex mcp list`: brains should read **OAuth**. Restart the ChatGPT desktop app or start a new Codex thread. The first time the plugin loads, open `/hooks` and trust the bundled brains hooks so automatic recall, capture, inbox delivery, and error feedback can run. +Everyday reading and writing is covered by default. For admin-gated tools or +performance insights, sign in asking for them explicitly (both also need the +matching access on your account): + +```sh +codex mcp login brains --scopes read,write,admin +codex mcp login brains --scopes read,write,perf_insights +``` + For a local checkout under development: ```sh codex plugin marketplace add /absolute/path/to/brains-plugins codex plugin add brains@brains +codex mcp login brains ``` +### Optional: conversation capture and the inbox + +The tools above work without this. Capture and the inbox are shell hooks that +authenticate separately from the MCP server and cannot read the credential Codex +keeps internally, so they need a brains API token of their own — find it in your +brains account settings. Without one they simply stay off. + +```sh +export BRAINS_API_TOKEN="" +``` + +That applies to Codex started from that shell. The macOS desktop app never +inherits a shell export, so set it for the app's launch environment instead and +restart the app: + +```sh +launchctl setenv BRAINS_API_TOKEN "" +``` + +This token is only for capture and the inbox. It is **not** how Codex +authenticates the brains tools — that is `codex mcp login brains` above. + ## Install for Claude Code Guided install (recommended): https://mybrains.ai/install/claude-code diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 51f3eb5..e4062e9 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -16,6 +16,12 @@ const PLUGIN = join(ROOT, "plugins", "brains"); const CODEX_MCP_SCOPES = ["read", "write"]; const CODEX_MCP_KEYS = ["type", "url", "scopes"]; +// The oldest Codex that can run this install, and the command that proves it. The floor is set by +// the `codex plugin` command surface — `codex mcp login` shipped much earlier, so probing the login +// would pass on versions that then fail at `codex plugin marketplace add`. +const CODEX_MIN_VERSION = "0.131"; +const CODEX_CAPABILITY_PROBE = "codex plugin --help"; + class AssertionError extends Error {} function readJson(path: string): any { @@ -50,6 +56,7 @@ const claudeHooks = readJson(join(PLUGIN, "hooks", "claude-hooks.json")); const codexHooks = readJson(join(PLUGIN, "hooks", "hooks.json")); const codexMcp = readJson(join(PLUGIN, ".mcp.json")); const turnHook = readFileSync(join(PLUGIN, "hooks", "brains-turn.sh"), "utf8"); +const readme = readFileSync(join(ROOT, "README.md"), "utf8"); const core = readFileSync(join(PLUGIN, "core.md"), "utf8"); const writeSkill = readFileSync(join(PLUGIN, "skills", "brains-write", "SKILL.md"), "utf8"); const coreNormalized = core.replace(/\s+/g, " "); @@ -68,6 +75,12 @@ assert(codexMarketplace.plugins[0]?.source?.path === "./plugins/brains", "Codex assert(codexMarketplace.plugins[0]?.policy?.installation === "AVAILABLE", "Codex install policy missing"); // Codex authenticates the MCP server on first use (`codex mcp login brains`), not during // `plugin add` — its plugin manifest has no field that could carry a credential at install time. +// +// Do NOT flip this back to ON_INSTALL on the observation that the desktop app kicks off a login +// during install: it does (its app-server install handlers start an OAuth login per declared +// server), but the install COMPLETES either way and the CLI performs no login at all, so ON_INSTALL +// would misdescribe the contract this repo documents. The startup auto-update path does not do it, +// which is why a plugin already installed stays logged out until someone runs the login. assert(codexMarketplace.plugins[0]?.policy?.authentication === "ON_USE", "Codex auth policy must be ON_USE"); const claudeEvents = Object.keys(claudeHooks.hooks).sort(); @@ -96,6 +109,18 @@ for (const command of hookScripts(codexHooks)) { ); } +// One file, one key, one server. The per-key allow-list below guards what the `brains` entry may +// declare; these two guard the file around it, so a second server — including a stdio `command` +// server, which Codex would launch on the user's machine — cannot ride along unnoticed. +assert( + JSON.stringify(Object.keys(codexMcp)) === JSON.stringify(["mcpServers"]), + `Codex MCP file may only contain mcpServers — got ${Object.keys(codexMcp).join(", ")}`, +); +assert( + JSON.stringify(Object.keys(codexMcp.mcpServers ?? {})) === JSON.stringify(["brains"]), + `Codex MCP file may only declare the brains server — got ${Object.keys(codexMcp.mcpServers ?? {}).join(", ")}`, +); + assert(codexMcp.mcpServers?.brains?.type === "http", "Codex brains MCP must be HTTP"); assert(codexMcp.mcpServers?.brains?.url === "https://mcp.mybrains.ai/mcp", "Codex brains MCP URL mismatch"); @@ -139,8 +164,13 @@ if (!codexOnPath) { const listed = codex(["mcp", "list", "--json"]); assert(listed.status === 0, `codex mcp list --json failed: ${listed.stderr}`); const servers = JSON.parse(listed.stdout); - const resolved = servers.find((s: any) => s.name === "brains"); - assert(resolved, "codex did not resolve a `brains` MCP server from the plugin declaration"); + // Assert the whole resolved set, not just that ours is in it — selecting `brains` out of a + // longer list would hide a sibling the plugin had quietly introduced. + assert( + JSON.stringify(servers.map((s: any) => s.name)) === JSON.stringify(["brains"]), + `codex must resolve exactly one server named brains — got ${servers.map((s: any) => s.name).join(", ") || "none"}`, + ); + const resolved = servers[0]; assert(resolved.enabled === true, "resolved brains MCP server must be enabled"); assert( resolved.transport?.type === "streamable_http", @@ -224,6 +254,39 @@ assert(writeSkillNormalized.includes("remains approvable"), "expired drafts must assert(writeSkillNormalized.includes("A bare `source` drafts nothing"), "source-only action fallback must stay prohibited"); assert(!/act_on_integration[^.]{0,200}request=/.test(writeSkillNormalized), "free-form action request must not return"); +// This README is the install instructions for anyone who finds the repo directly rather than the +// guided page, so it has to carry the same contract. It documented `export BRAINS_API_TOKEN` as the +// way in long after that stopped being able to work, which is exactly the drift these pin. +const codexReadme = readme.slice( + readme.indexOf("## Install for Codex"), + readme.indexOf("## Install for Claude Code"), +); +assert(codexReadme.length > 0, "README must document a Codex install"); +assert( + codexReadme.includes("codex mcp login brains"), + "README's Codex install must sign Codex in with `codex mcp login brains`", +); +assert( + codexReadme.includes(CODEX_MIN_VERSION) && codexReadme.includes(CODEX_CAPABILITY_PROBE), + `README's Codex install must state the ${CODEX_MIN_VERSION} floor and the \`${CODEX_CAPABILITY_PROBE}\` check`, +); +// The token is still documented, but only under the optional capture/inbox heading — never in the +// install sequence itself. Anything above that heading claiming a token is how you get in is the +// regression this catches. +const optionalHeadingIndex = codexReadme.indexOf("### Optional"); +assert(optionalHeadingIndex > 0, "README must keep the optional capture/inbox section for Codex"); +const codexPrerequisites = codexReadme.slice(0, optionalHeadingIndex); +for (const forbidden of ["BRAINS_API_TOKEN", "launchctl setenv"]) { + assert( + !codexPrerequisites.includes(forbidden), + `README must not present \`${forbidden}\` as a Codex install prerequisite — it belongs under the optional capture/inbox section`, + ); +} +assert( + codexReadme.slice(optionalHeadingIndex).includes("launchctl setenv"), + "README's optional section must keep the desktop launchctl path — a desktop app inherits no shell export", +); + assert( turnHook.includes('CLIENT="claude"'), "shared turn hook must default Claude Code captures to the Claude CLI", From 2417d2ae9308170f1f5b973c4982b3ca975cbf12 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Tue, 28 Jul 2026 14:08:40 +0300 Subject: [PATCH 3/3] test(brains): fail loudly when the README's Codex section can't be located MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README checks sliced between two headings without confirming either was found. A missing end heading gives indexOf -1, and slice(start, -1) widens the region to almost the whole file — the assertions would then read the wrong section and still pass, which is the failure mode a contract test exists to prevent. Resolve both indices first, assert each was found and that they are in order, then slice. --- tests/plugin-contract/run.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index e4062e9..da9a604 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -257,11 +257,18 @@ assert(!/act_on_integration[^.]{0,200}request=/.test(writeSkillNormalized), "fre // This README is the install instructions for anyone who finds the repo directly rather than the // guided page, so it has to carry the same contract. It documented `export BRAINS_API_TOKEN` as the // way in long after that stopped being able to work, which is exactly the drift these pin. -const codexReadme = readme.slice( - readme.indexOf("## Install for Codex"), - readme.indexOf("## Install for Claude Code"), +// Resolve both delimiters before slicing. A missing end heading yields -1, and +// `slice(start, -1)` would silently widen the region to almost the whole file — +// every assertion below would then pass while reading the wrong section. +const codexStart = readme.indexOf("## Install for Codex"); +const claudeStart = readme.indexOf("## Install for Claude Code"); +assert(codexStart >= 0, "README must document a Codex install"); +assert(claudeStart >= 0, "README must document a Claude Code install"); +assert( + claudeStart > codexStart, + "README's Claude Code section must follow the Codex one — the Codex checks below slice between them", ); -assert(codexReadme.length > 0, "README must document a Codex install"); +const codexReadme = readme.slice(codexStart, claudeStart); assert( codexReadme.includes("codex mcp login brains"), "README's Codex install must sign Codex in with `codex mcp login brains`",