Skip to content

fix(brains): Codex owns the MCP credential via its own OAuth login - #16

Merged
olegshmuelov merged 3 commits into
mainfrom
fix/dist-012-codex-oauth
Jul 30, 2026
Merged

fix(brains): Codex owns the MCP credential via its own OAuth login#16
olegshmuelov merged 3 commits into
mainfrom
fix/dist-012-codex-oauth

Conversation

@olegshmuelov

@olegshmuelov olegshmuelov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • plugins/brains/.mcp.json drops bearer_token_env_var and declares scopes: ["read", "write"] — Codex signs itself in via codex mcp login brains (browser consent, dynamic client registration, credential kept by Codex). The env-var declaration made Codex fail hard at startup when the variable was missing — nothing in a plugin install can set it — and its presence also disabled the OAuth login path entirely.
  • README Codex section rewritten to the sign-in flow: version floor 0.131 with a codex plugin --help probe, marketplace add, plugin add, codex mcp login brains, the consent-screen expectation, and the scope escape hatches. The token moves under "Optional: conversation capture and the inbox" with both the shell-export and desktop launchctl paths.
  • .agents/plugins/marketplace.json authentication → ON_USE; the contract-test comment records the desktop app-server nuance so it isn't flipped back on a shallow re-derivation.
  • Both plugin manifests → 2.4.0.
  • Contract test hardened: bearer absence + exact scopes + per-key allow-list, file-level shape guards (only mcpServers, only brains — a stdio sibling can't ride along), a resolved-config check against a real Codex (skips visibly when codex isn't on PATH), no-token hooks must exit 0 silently (load-bearing now that capture/inbox are opt-in), and README pins with hardened section delimiters.

Hooks are unchanged: capture + the inbox still read BRAINS_API_TOKEN, now as an opt-in rather than the price of admission.

Test

  • All three suites green; resolved-config check exercised against codex-cli 0.145.0.
  • 12 mutations red on the intended assertions: scope typo, bearer returning, stray ignored key, ON_INSTALL restored, http sibling, stdio command sibling, stray top-level key, README export-as-prerequisite, README dropping launchctl, README floor regression, README missing Claude heading, README section reorder.
  • Fresh CODEX_HOME end to end against a stub AS: DCR sends client_name: "Codex" + scope=read write, login succeeds, codex mcp list reads OAuth, and MCP startup presents the stored Bearer with no env var.
  • Hooks smoke: with a token → POST with the bearer; without → silent exit 0, no request.

Known gap: CI is bun-only, so the resolved-config check skips there (it runs locally and at release).

Do not merge before the guide is live on prod

Codex auto-refreshes git marketplaces and force-reinstalls plugins unconditionally, so this merge migrates HEAD-tracking installs at their next launch — it is the go-live switch, not a staging step. Sequence: ssvlabs/brains#1066 → stage → released to main (guide live) → merge this. The small set of existing token-based installs is notified directly when this merges.

Before merging: run bun run tests/plugin-contract/run.ts locally with codex ≥ 0.131 on PATH and confirm it prints resolved-config check OK — the suite prints either that or a visible SKIP, so the evidence is unambiguous. Last executed against codex-cli 0.145.0.

BRNS-DIST-012.

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.
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.
…cated

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.

@nir-ssvlabs nir-ssvlabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving the code — it's clean, and the contract test is the strongest part.

Verified:

  • .mcp.json is now {type, url, scopes:["read","write"]} with bearer_token_env_var gone — consistent with the web-side default I reviewed in ssvlabs/brains#1066, and matching the mint-side scope vocabulary (read/write, with admin/perf_insights as the login escape hatches). The scopes key is what Codex forwards to DCR (your fresh-CODEX_HOME run against the stub AS confirms scope=read write), so this is the field that actually drives the request, not decoration.
  • The contract test pins the right invariants and can't silently rot: bearer-absence (with the "presence disables OAuth login" reason recorded), exact scopes, strict key allow-lists on both the file and the server object (a stdio/http sibling can't ride along), the resolved-config check against a real Codex, and no-token hooks exiting 0 silently — load-bearing now that capture/inbox are opt-in.
  • Versions bump uniformly 2.3.3 → 2.4.0 (correct increment from the #13 that just merged), and marketplace.jsonON_USE matches the sign-in-on-use model. Hooks are untouched and still key off BRAINS_API_TOKEN independently.

⚠️ Not a code issue, but the reason this can't merge yet — restating only because it's the go-live trigger: ssvlabs/brains#1066 is still open on stage and is not in the 2026-07-28 release batch, so the updated /install/codex-cli guide is not live on prod. Since merging this force-reinstalls HEAD-tracking Codex installs at their next launch, it must land only after #1066 reaches main — exactly the sequence you documented. My approval is on code readiness; please hold the merge until the guide is live.

Nothing critical in the diff itself.

@RaekwonIII RaekwonIII left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the pair together; the server-side and cross-repo claims check out. Not approving yet — one thing I'd like addressed first.

Main concern — the strongest test only runs locally / at release

The resolved-config check (asking a real Codex what it made of the declaration) is the one assertion that catches an upstream Codex change that stops honouring the plugin MCP shape — the static key/scope asserts can all stay green while resolution silently breaks or falls back onto the bearer path. But CI is bun-only with no codex on PATH, so it SKIPs there and only runs locally and at release.

That's disclosed, and I'm not necessarily asking CI to install codex — but I'd like the gap closed or bounded before this merges, since this merge is the go-live switch for every HEAD-tracking install. Options:

  • Run it in a release-gate job against a pinned/known Codex version (doesn't have to be per-PR).
  • Make the skip loud at release — fail if the check didn't actually execute, so "skipped" can't masquerade as "passed" on the one merge that migrates every install.
  • Or document explicitly who runs it, and against which codex version, at go-live.

I'd want the resolved-config check to have definitely run against a real codex before this lands — not just be present in the suite.

What checks out (verified against ssvlabs/brains)

  • Scopes are real server-side: read | write | admin | perf_insights are the exact recognized set (packages/mcp-auth), and DCR + loopback (127.0.0.1) redirect are supported (apps/mcp/src/oauth.ts).
  • The scope pin is load-bearing, not cosmetic: the server maps no/unknown scopes to an unscoped = full-access token, so dropping scopes really would request every advertised scope and mint an admin-carrying token for every user. Baking ["read","write"] + asserting it exactly is a genuine control.
  • ON_USE and the bearer-absence invariant are reasoned correctly.

Nice work on the contract-test hardening — the file-shape guards (only mcpServers / only brains, no stdio sibling) and the no-token silent-exit assertions are the right instincts.

@olegshmuelov

Copy link
Copy Markdown
Contributor Author

@RaekwonIII Went with your third option — documentation, no new machinery. The "Do not merge" section in the description now names the step: before this merges, the merger runs the contract suite locally with codex ≥ 0.131 on PATH and confirms resolved-config check OK printed (the suite prints either that or a visible SKIP, so a skip can't pass silently). Last executed against codex-cli 0.145.0. That run plus a real-account prod login smoke are the go-live steps, sequenced after ssvlabs/brains#1066 is live on prod.

Deliberately no CI change: PRs here are rare, so PR-triggered CI can't catch upstream drift in time anyway, and an unpinned codex install would make every unrelated PR inherit OpenAI's release quality. If upstream ever breaks resolution once, a scheduled job is the first follow-up.

@stefan-ssv-labs stefan-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ review-pr: clean against BRNS-DIST-012 — the plugin resolves through Codex OAuth without a bearer env var; merge only after ssvlabs/brains#1066 is live on production.

@RaekwonIII RaekwonIII left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this addresses it. The go-live checklist step makes the skip load-bearing: requiring resolved-config check OK (never a silent SKIP) at merge time is exactly the guarantee I was after, and putting it at the go-live gate rather than per-PR is the right call. Agreed on not adding CI — rare PRs mean PR-triggered runs wouldn't catch upstream drift anyway, and an unpinned codex would tie every unrelated PR to OpenAI's release quality; a scheduled job is the sensible escalation if resolution ever breaks once.

Approving, with the standing caveat already in the description: #1066 to prod first, then the local real-codex contract run (resolved-config check OK) + prod login smoke, then merge this.

@olegshmuelov
olegshmuelov merged commit 50424b2 into main Jul 30, 2026
3 checks passed
@olegshmuelov
olegshmuelov deleted the fix/dist-012-codex-oauth branch July 30, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants