fix(codex): detect ChatGPT auth from id_token claims so wrap/init emit requires_openai_auth (#3212)

Fixes #3206.

## The report

`headroom wrap codex` / `init codex` write a provider block without
`requires_openai_auth = true`. Codex then attaches **no `Authorization`
header**, and every request through the proxy 401s:

```
unexpected status 401 Unauthorized: Missing bearer or basic authentication in header
```

Silently — `headroom doctor` reported green throughout. The reporter
lost ~15h of scheduled Codex automation before bisecting it.

## Not the fix the issue suggested

The issue proposes adding the line unconditionally. **That would
re-break API-key users**, which is the regression `requires_openai_auth`
was made conditional for in the first place (#406) — the flag forces
Codex to demand an OpenAI OAuth login.

All three writers (install provider-scope, `init codex`, `wrap codex`)
*already* call `codex_uses_chatgpt_auth()` and emit the key when it
returns True. **The bug is in the detection, not the writers.**

## Root cause

`codex_uses_chatgpt_auth` recognised two shapes:

1. `auth_mode == "chatgpt"`
2. a top-level `tokens.account_id`

Newer Codex can write an `auth.json` with **neither** — the account
identity lives only in the `id_token` claims, under
`https://api.openai.com/auth.chatgpt_account_id`. That config reads as
API-key mode, the flag is omitted, and every request 401s.

Verified against a real `auth.json`: the JWT claim carries the *same*
account id as the top-level key, so it is a faithful signal for the
shape that lacks it.

## Fix

A third detection tier, consulted only when the first two are absent:

| Shape | Before | After |
|---|---|---|
| `auth_mode = "chatgpt"` | True | True |
| legacy `tokens.account_id` | True | True |
| **only the `id_token` claim** | **False** ← the bug | **True** |
| `auth_mode = "apikey"` + ChatGPT id_token | False | **False** (#406
stays closed) |
| API key, no tokens | False | False |
| id_token without the claim / malformed / blank id | False | False |

The payload is **decoded, not verified**. It is a local file the user
already owns, and the result only chooses which key we write into their
own `config.toml` — nothing is authenticated or authorised on the
strength of it. An API-key user has no ChatGPT id_token, so this cannot
resurrect #406, and an explicit `auth_mode` still wins outright (pinned
by test).

## Doctor stops reporting a false green

This failure is invisible from every other signal — proxy up, provider
block present. So the codex check now WARNs when the config is routed,
the user is on ChatGPT auth, **and** the block lacks the flag, naming
the re-run that repairs it.

It only runs when the flag is already missing, and the keyring fallback
it can reach is bounded by an existing 3s timeout, so `doctor` stays
fast. API-key users are never nagged.

## Existing configs

Self-healing — all three writers strip and regenerate the managed block
on every run, so re-running `wrap`/`init` emits the key now that
detection is correct. No separate migration needed.

## Testing

99 passing across the two suites. Confirmed **discriminating**: 3 of the
new tests fail against unfixed source and pass after —

- `test_chatgpt_auth_detected_from_id_token_claims_alone`
- `test_provider_block_emits_requires_openai_auth_for_the_new_shape`
-
`TestCodexRouting::test_chatgpt_auth_without_requires_openai_auth_warns`

plus explicit coverage for the #406 guard, malformed tokens, blank
account ids, and the API-key-not-nagged case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tejas Chopra 2026-08-22 15:29:54 -07:00 committed by GitHub
parent 8f3e33a00e
commit 2f81fa5931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 221 additions and 1 deletions

View file

@ -433,9 +433,39 @@ def check_codex_routing(config_path: Path, port: int) -> CheckResult:
summary=f"routed to port {match.group(1)}, but doctor probed port {port}", summary=f"routed to port {match.group(1)}, but doctor probed port {port}",
hint=f"re-run with: headroom doctor --port {match.group(1)}", hint=f"re-run with: headroom doctor --port {match.group(1)}",
) )
# Routed, but Codex may still attach no credentials. A ChatGPT-OAuth user
# needs `requires_openai_auth = true` in the provider block or Codex sends
# no Authorization header at all and every request 401s with "Missing
# bearer" (#3206). That failure is invisible from here -- the proxy is up,
# the block is present -- so this check is the only place it can surface.
if _codex_block_missing_openai_auth(text, config_path):
return CheckResult(
name=name,
status=WARN,
summary="routed, but Codex will send no Authorization (missing requires_openai_auth)",
hint="re-run: headroom wrap codex (or headroom init codex) to rewrite the block",
)
return CheckResult(name=name, status=PASS, summary=f"routed ({config_path})") return CheckResult(name=name, status=PASS, summary=f"routed ({config_path})")
def _codex_block_missing_openai_auth(text: str, config_path: Path) -> bool:
"""ChatGPT-OAuth Codex routed without ``requires_openai_auth`` (#3206)."""
start = text.find("[model_providers.headroom]")
if start == -1:
return False
rest = text[start + len("[model_providers.headroom]") :]
end = rest.find("\n[")
block = rest if end == -1 else rest[:end]
if "requires_openai_auth" in block:
return False
try:
from headroom.providers.codex.install import codex_uses_chatgpt_auth
return codex_uses_chatgpt_auth(config_path.parent / "auth.json")
except Exception: # pragma: no cover - never let a doctor check crash
return False
def check_shell_env(environ: Mapping[str, str], port: int) -> CheckResult: def check_shell_env(environ: Mapping[str, str], port: int) -> CheckResult:
"""Is the *current shell* pointed at the proxy for ad-hoc runs?""" """Is the *current shell* pointed at the proxy for ad-hoc runs?"""
name = "shell env" name = "shell env"

View file

@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import os import os
import re import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any
try: try:
import tomllib import tomllib
@ -99,10 +101,47 @@ def codex_uses_chatgpt_auth(auth_path: Path) -> bool:
tokens = data.get("tokens") tokens = data.get("tokens")
if isinstance(tokens, dict): if isinstance(tokens, dict):
account_id = tokens.get("account_id") account_id = tokens.get("account_id")
return isinstance(account_id, str) and bool(account_id.strip()) if isinstance(account_id, str) and account_id.strip():
return True
return _id_token_carries_chatgpt_account(tokens.get("id_token"))
return False return False
def _id_token_carries_chatgpt_account(raw: Any) -> bool:
"""Whether an ``id_token`` carries the ChatGPT account claim (#3206).
Newer Codex releases can write an ``auth.json`` with neither ``auth_mode``
nor a top-level ``tokens.account_id``; the account identity lives only in
the ``id_token`` claims. Those configs then read as API-key mode, so
``requires_openai_auth`` is omitted, Codex attaches no Authorization
header, and every request 401s with "Missing bearer".
The payload is decoded, not verified. This is a local config file the user
already owns, and the result only decides which key we write into their own
``config.toml`` -- nothing is authenticated or authorised on the strength
of it. An API-key user has no ChatGPT id_token, so this cannot resurrect
the forced-OAuth-login regression in #406.
"""
if not isinstance(raw, str):
return False
parts = raw.split(".")
if len(parts) != 3:
return False
payload = parts[1]
payload += "=" * (-len(payload) % 4)
try:
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
except Exception:
return False
if not isinstance(claims, dict):
return False
auth_claim = claims.get("https://api.openai.com/auth")
if not isinstance(auth_claim, dict):
return False
account_id = auth_claim.get("chatgpt_account_id")
return isinstance(account_id, str) and bool(account_id.strip())
def build_provider_section( def build_provider_section(
*, *,
port: int, port: int,

View file

@ -488,6 +488,56 @@ class TestCodexRouting:
path.write_bytes(b"\xff\xfe garbage \x00") path.write_bytes(b"\xff\xfe garbage \x00")
assert check_codex_routing(path, 8787).status == WARN assert check_codex_routing(path, 8787).status == WARN
# -- requires_openai_auth (#3206) ------------------------------------
# Codex attaches no Authorization header to a custom provider unless the
# block carries requires_openai_auth. A ChatGPT-OAuth user then 401s on
# every request with "Missing bearer" while doctor reported green -- the
# reason one report went 15h before anyone could see the cause.
@staticmethod
def _routed(tmp_path, *, requires_auth: bool):
path = tmp_path / "config.toml"
block = (
"[model_providers.headroom]\n"
'base_url = "http://127.0.0.1:8787/v1"\n'
"supports_websockets = true\n"
)
if requires_auth:
block += "requires_openai_auth = true\n"
path.write_text(block, encoding="utf-8")
return path
@staticmethod
def _chatgpt_auth(tmp_path):
(tmp_path / "auth.json").write_text('{"auth_mode": "chatgpt"}', encoding="utf-8")
def test_chatgpt_auth_without_requires_openai_auth_warns(self, tmp_path):
path = self._routed(tmp_path, requires_auth=False)
self._chatgpt_auth(tmp_path)
result = check_codex_routing(path, 8787)
assert result.status == WARN
assert "Authorization" in result.summary
def test_chatgpt_auth_with_requires_openai_auth_passes(self, tmp_path):
path = self._routed(tmp_path, requires_auth=True)
self._chatgpt_auth(tmp_path)
assert check_codex_routing(path, 8787).status == PASS
def test_api_key_user_without_requires_openai_auth_still_passes(self, tmp_path):
"""API-key users must not be nagged -- the flag would break them (#406)."""
path = self._routed(tmp_path, requires_auth=False)
(tmp_path / "auth.json").write_text('{"OPENAI_API_KEY": "sk-test"}', encoding="utf-8")
assert check_codex_routing(path, 8787).status == PASS
def test_no_auth_json_does_not_warn(self, tmp_path):
path = self._routed(tmp_path, requires_auth=False)
assert check_codex_routing(path, 8787).status == PASS
class TestShellEnv: class TestShellEnv:
def test_unset_warns(self): def test_unset_warns(self):

View file

@ -85,3 +85,104 @@ def test_codex_provider_section_supports_custom_markers() -> None:
assert section.endswith("# --- end ---\n") assert section.endswith("# --- end ---\n")
assert 'base_url = "http://127.0.0.1:9100/v1"' in section assert 'base_url = "http://127.0.0.1:9100/v1"' in section
assert 'env_key = "OPENAI_API_KEY"' not in section assert 'env_key = "OPENAI_API_KEY"' not in section
# ---------------------------------------------------------------------------
# ChatGPT-auth detection from the id_token claims (#3206)
#
# Newer Codex releases can write an auth.json with neither `auth_mode` nor a
# top-level `tokens.account_id`; the account identity lives only in the
# id_token claims. Those configs read as API-key mode, so requires_openai_auth
# is omitted, Codex attaches no Authorization header, and every request 401s
# with "Missing bearer" -- silently, with doctor reporting green.
# ---------------------------------------------------------------------------
def _unsigned_jwt(claims: dict[str, object]) -> str:
import base64
import json as _json
def seg(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
header = seg(b'{"alg":"none"}')
payload = seg(_json.dumps(claims).encode("utf-8"))
return ".".join((header, payload, "sig"))
_CHATGPT_CLAIMS: dict[str, object] = {
"https://api.openai.com/auth": {
"chatgpt_account_id": "1a155430-5551-47f4-9c7b-aeab7983f24a",
"chatgpt_plan_type": "pro",
}
}
def _write_auth(tmp_path, document: dict[str, object]): # noqa: ANN001, ANN202
import json as _json
path = tmp_path / "auth.json"
path.write_text(_json.dumps(document), encoding="utf-8")
return path
def test_chatgpt_auth_detected_from_id_token_claims_alone(tmp_path) -> None:
"""The #3206 shape: no auth_mode, no tokens.account_id, only the JWT."""
path = _write_auth(tmp_path, {"tokens": {"id_token": _unsigned_jwt(_CHATGPT_CLAIMS)}})
assert codex_uses_chatgpt_auth(path) is True
def test_explicit_api_key_mode_still_wins_over_a_chatgpt_id_token(tmp_path) -> None:
"""Guards the #406 regression: API-key users must not get forced OAuth."""
path = _write_auth(
tmp_path,
{"auth_mode": "apikey", "tokens": {"id_token": _unsigned_jwt(_CHATGPT_CLAIMS)}},
)
assert codex_uses_chatgpt_auth(path) is False
def test_api_key_config_without_tokens_is_not_chatgpt(tmp_path) -> None:
path = _write_auth(tmp_path, {"OPENAI_API_KEY": "sk-test"})
assert codex_uses_chatgpt_auth(path) is False
def test_id_token_without_the_chatgpt_claim_is_not_chatgpt(tmp_path) -> None:
path = _write_auth(tmp_path, {"tokens": {"id_token": _unsigned_jwt({"sub": "user"})}})
assert codex_uses_chatgpt_auth(path) is False
def test_malformed_id_token_is_not_chatgpt(tmp_path) -> None:
for bogus in ("not-a-jwt", "a.b", "a.!!!not-base64!!!.c", ""):
path = _write_auth(tmp_path, {"tokens": {"id_token": bogus}})
assert codex_uses_chatgpt_auth(path) is False, bogus
def test_blank_chatgpt_account_id_is_not_chatgpt(tmp_path) -> None:
claims = {"https://api.openai.com/auth": {"chatgpt_account_id": " "}}
path = _write_auth(tmp_path, {"tokens": {"id_token": _unsigned_jwt(claims)}})
assert codex_uses_chatgpt_auth(path) is False
def test_legacy_account_id_still_detected(tmp_path) -> None:
path = _write_auth(tmp_path, {"tokens": {"account_id": "acct-123"}})
assert codex_uses_chatgpt_auth(path) is True
def test_provider_block_emits_requires_openai_auth_for_the_new_shape(tmp_path) -> None:
"""End of the chain: the JWT-only shape must produce the key Codex needs."""
path = _write_auth(tmp_path, {"tokens": {"id_token": _unsigned_jwt(_CHATGPT_CLAIMS)}})
block = build_provider_section(
port=8787,
name="Headroom",
include_markers=False,
requires_openai_auth=codex_uses_chatgpt_auth(path),
)
assert "requires_openai_auth = true" in block