diff --git a/headroom/cli/doctor.py b/headroom/cli/doctor.py index 03d0ce58e..4187bd1a2 100644 --- a/headroom/cli/doctor.py +++ b/headroom/cli/doctor.py @@ -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}", 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})") +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: """Is the *current shell* pointed at the proxy for ad-hoc runs?""" name = "shell env" diff --git a/headroom/providers/codex/install.py b/headroom/providers/codex/install.py index b5da67833..234ef70fc 100644 --- a/headroom/providers/codex/install.py +++ b/headroom/providers/codex/install.py @@ -2,11 +2,13 @@ from __future__ import annotations +import base64 import json import os import re import subprocess from pathlib import Path +from typing import Any try: import tomllib @@ -99,10 +101,47 @@ def codex_uses_chatgpt_auth(auth_path: Path) -> bool: tokens = data.get("tokens") if isinstance(tokens, dict): 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 +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( *, port: int, diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 7f68cd4e6..30adbeb51 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -488,6 +488,56 @@ class TestCodexRouting: path.write_bytes(b"\xff\xfe garbage \x00") 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: def test_unset_warns(self): diff --git a/tests/test_provider_codex_install.py b/tests/test_provider_codex_install.py index 05283762f..30e75bb27 100644 --- a/tests/test_provider_codex_install.py +++ b/tests/test_provider_codex_install.py @@ -85,3 +85,104 @@ def test_codex_provider_section_supports_custom_markers() -> None: assert section.endswith("# --- end ---\n") assert 'base_url = "http://127.0.0.1:9100/v1"' 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