mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(codex): detect keyring-backed ChatGPT auth (#2478)
## Description Headroom currently treats missing `auth.json` as “not ChatGPT auth” for Codex, which breaks keyring-backed ChatGPT sessions on Codex CLI 0.144.6 because those sessions intentionally may not store credentials in the file. This updates the Codex auth detector to keep the existing file-backed fast path and fall back to Codex-owned auth metadata when the session is keyring-backed or auto-backed, so `requires_openai_auth = true` is emitted only for real ChatGPT logins. Closes #2474 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - extend Codex auth detection so keyring-backed and auto-backed sessions can be classified from Codex-owned auth metadata when `auth.json` is absent - preserve the current file-backed ChatGPT, API-key, malformed-file, and fail-closed behaviors - add focused install-layer regression coverage for the new keyring path and adjacent negative space ## Testing - [x] Unit tests pass (`uv run pytest tests/test_install/test_codex_install.py -q`) - [x] Linting passes (`uv run ruff check headroom/providers/codex/install.py tests/test_install/test_codex_install.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_install/test_codex_install.py -q ============================= test session starts ============================= platform win32 -- Python 3.12.13, pytest-9.0.3, pluggy-1.6.0 collected 9 items tests\test_install\test_codex_install.py ......... [100%] ============================== 9 passed in 0.24s ============================== uv run ruff check headroom/providers/codex/install.py tests/test_install/test_codex_install.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows, Codex CLI 0.144.6 available locally, Python 3.12.13 via `uv` - Exact command / steps: `codex login status`; `Measure-Command { codex login status > $null }`; focused pytest and Ruff commands above - Observed result: `codex login status` returns `stdout=''` and `stderr='Logged in using ChatGPT\n'`; the status probe measured `71.40` ms; pytest reports `9 passed in 0.24s`; keyring ChatGPT emits `requires_openai_auth = true`, non-ChatGPT and failed probes omit it, and file-backed ChatGPT/API-key cases remain true/false - Not tested: live local keyring-backed Codex login ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes `CHANGELOG.md` stays untouched because Headroom generates release notes from conventional commits. The PR should only claim the Codex-owned detection path and focused local regression coverage; the live keyring session proof remains a follow-up owner check.
This commit is contained in:
parent
a2e42fb877
commit
46293f4daf
2 changed files with 177 additions and 2 deletions
|
|
@ -3,9 +3,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError: # Python < 3.11
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
|
||||
from headroom._subprocess import run
|
||||
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||
from headroom.install.paths import codex_config_path
|
||||
|
||||
|
|
@ -36,6 +44,32 @@ _ROOT_MODEL_PROVIDER_RE = re.compile(r"^[ \t]*model_provider[ \t]*=")
|
|||
_ROOT_OPENAI_BASE_URL_RE = re.compile(r"^[ \t]*openai_base_url[ \t]*=")
|
||||
|
||||
|
||||
def _codex_credential_store(config_dir: Path) -> str | None:
|
||||
try:
|
||||
config = tomllib.loads((config_dir / "config.toml").read_text(encoding="utf-8"))
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
store = config.get("cli_auth_credentials_store")
|
||||
return store.lower() if isinstance(store, str) else None
|
||||
|
||||
|
||||
def _codex_login_status(config_dir: Path) -> bool:
|
||||
env = {**os.environ, "CODEX_HOME": str(config_dir)}
|
||||
try:
|
||||
result = run(
|
||||
["codex", "login", "status"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=3,
|
||||
env=env,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
message = result.stdout.strip() or result.stderr.strip()
|
||||
return result.returncode == 0 and message.casefold() == "logged in using chatgpt"
|
||||
|
||||
|
||||
def codex_uses_chatgpt_auth(auth_path: Path) -> bool:
|
||||
"""Whether Codex authenticated via ChatGPT OAuth (vs an OpenAI API key).
|
||||
|
||||
|
|
@ -45,8 +79,16 @@ def codex_uses_chatgpt_auth(auth_path: Path) -> bool:
|
|||
emit it only in ChatGPT-OAuth mode, read from the sibling ``auth.json``.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
raw = auth_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
if _codex_credential_store(auth_path.parent) not in {"keyring", "auto"}:
|
||||
return False
|
||||
return _codex_login_status(auth_path.parent)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
|
|
|
|||
133
tests/test_install/test_codex_install.py
Normal file
133
tests/test_install/test_codex_install.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from headroom.install.models import DeploymentManifest
|
||||
from headroom.providers.codex.install import (
|
||||
_codex_login_status,
|
||||
apply_provider_scope,
|
||||
build_provider_section,
|
||||
codex_uses_chatgpt_auth,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> DeploymentManifest:
|
||||
return DeploymentManifest(
|
||||
profile="test",
|
||||
preset="persistent-service",
|
||||
runtime_kind="python",
|
||||
supervisor_kind="service",
|
||||
scope="provider",
|
||||
provider_mode="manual",
|
||||
targets=["codex"],
|
||||
port=8787,
|
||||
host="127.0.0.1",
|
||||
backend="anthropic",
|
||||
memory_db_path=str(tmp_path / "memory.db"),
|
||||
tool_envs={},
|
||||
)
|
||||
|
||||
|
||||
def _login_status(
|
||||
stdout: str = "",
|
||||
*,
|
||||
stderr: str = "",
|
||||
returncode: int = 0,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def test_keyring_chatgpt_auth_emits_provider_flag(monkeypatch, tmp_path: Path) -> None:
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('cli_auth_credentials_store = "keyring"\n', encoding="utf-8")
|
||||
monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.codex.install.run",
|
||||
lambda *args, **kwargs: _login_status(stderr="Logged in using ChatGPT\n"),
|
||||
)
|
||||
|
||||
apply_provider_scope(_manifest(tmp_path))
|
||||
|
||||
assert "requires_openai_auth = true" in config.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_keyring_non_chatgpt_auth_keeps_provider_flag_off(monkeypatch, tmp_path: Path) -> None:
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text('cli_auth_credentials_store = "keyring"\n', encoding="utf-8")
|
||||
monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.codex.install.run",
|
||||
lambda *args, **kwargs: _login_status(stderr="Logged in using API key\n"),
|
||||
)
|
||||
|
||||
apply_provider_scope(_manifest(tmp_path))
|
||||
|
||||
assert "requires_openai_auth" not in config.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_auto_store_chatgpt_auth_is_detected(monkeypatch, tmp_path: Path) -> None:
|
||||
auth = tmp_path / "auth.json"
|
||||
(tmp_path / "config.toml").write_text('cli_auth_credentials_store = "auto"\n', encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.codex.install.run",
|
||||
lambda *args, **kwargs: _login_status(stderr="Logged in using ChatGPT\n"),
|
||||
)
|
||||
|
||||
assert codex_uses_chatgpt_auth(auth) is True
|
||||
|
||||
|
||||
def test_file_backed_auth_preserves_existing_modes(tmp_path: Path) -> None:
|
||||
auth = tmp_path / "auth.json"
|
||||
auth.write_text('{"auth_mode": "CHATGPT"}', encoding="utf-8")
|
||||
assert codex_uses_chatgpt_auth(auth) is True
|
||||
auth.write_text('{"auth_mode": "apikey", "tokens": {"account_id": "acct"}}', encoding="utf-8")
|
||||
assert codex_uses_chatgpt_auth(auth) is False
|
||||
|
||||
|
||||
def test_legacy_file_backed_account_id_stays_supported(tmp_path: Path) -> None:
|
||||
auth = tmp_path / "auth.json"
|
||||
auth.write_text('{"tokens": {"account_id": "acct"}}', encoding="utf-8")
|
||||
assert codex_uses_chatgpt_auth(auth) is True
|
||||
|
||||
|
||||
def test_missing_or_failed_login_status_fails_closed(monkeypatch, tmp_path: Path) -> None:
|
||||
(tmp_path / "config.toml").write_text(
|
||||
'cli_auth_credentials_store = "keyring"\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.codex.install.run",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(OSError()),
|
||||
)
|
||||
assert codex_uses_chatgpt_auth(tmp_path / "auth.json") is False
|
||||
|
||||
|
||||
def test_login_status_probe_uses_codex_contract(monkeypatch, tmp_path: Path) -> None:
|
||||
calls: list[tuple[list[str], dict]] = []
|
||||
|
||||
def probe(command: list[str], **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return _login_status(stderr="Logged in using ChatGPT\n")
|
||||
|
||||
monkeypatch.setattr("headroom.providers.codex.install.run", probe)
|
||||
|
||||
assert _codex_login_status(tmp_path) is True
|
||||
assert calls[0][0] == ["codex", "login", "status"]
|
||||
assert calls[0][1]["timeout"] == 3
|
||||
assert calls[0][1]["env"]["CODEX_HOME"] == str(tmp_path)
|
||||
assert calls[0][1]["capture_output"] is True
|
||||
|
||||
|
||||
def test_login_status_probe_accepts_stdout_or_stderr(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(
|
||||
"headroom.providers.codex.install.run",
|
||||
lambda *args, **kwargs: _login_status(stdout="Logged in using ChatGPT\n"),
|
||||
)
|
||||
|
||||
assert _codex_login_status(tmp_path) is True
|
||||
|
||||
|
||||
def test_provider_section_still_emits_flag_when_requested() -> None:
|
||||
assert "requires_openai_auth = true" in build_provider_section(
|
||||
port=8787, name="Headroom", requires_openai_auth=True
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue