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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
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.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
|
||||||
from headroom.install.paths import codex_config_path
|
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]*=")
|
_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:
|
def codex_uses_chatgpt_auth(auth_path: Path) -> bool:
|
||||||
"""Whether Codex authenticated via ChatGPT OAuth (vs an OpenAI API key).
|
"""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``.
|
emit it only in ChatGPT-OAuth mode, read from the sibling ``auth.json``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
raw = auth_path.read_text(encoding="utf-8")
|
||||||
except (OSError, ValueError):
|
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
|
return False
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return False
|
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