Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 69 additions & 7 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
_save_auth_store(auth_store)


def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
imported = _import_codex_cli_tokens()
# Require BOTH tokens before adopting: persisting a payload without a
# usable refresh_token would only break the next refresh cycle.
if not (
imported
and str(imported.get("access_token", "") or "").strip()
and str(imported.get("refresh_token", "") or "").strip()
):
return None
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
_save_codex_tokens(imported)
return dict(imported)


def refresh_codex_oauth_pure(
access_token: str,
refresh_token: str,
Expand Down Expand Up @@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(

Saves the new tokens to Hermes auth store automatically.
"""
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
try:
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
except AuthError as exc:
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
# Codex OAuth token (per profile + top-level), separate from the Codex
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
# the Codex CLI (or another Hermes process) rotates the shared token,
# this frozen copy's refresh_token goes stale and the refresh fails with
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
# Before surfacing that as a hard 401 to the turn, adopt the canonical
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
# idle profiles / desktop sessions recover automatically instead of
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
# keep relogin_required=False — the stored token is still valid there, so
# we never self-heal those and re-raise unchanged.
if not getattr(exc, "relogin_required", False):
raise
imported = _recover_codex_tokens_from_cli(
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
)
if not imported:
raise
return imported

updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
Expand Down Expand Up @@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
credential. See issue #32992.
"""
read_error: Optional[AuthError] = None
try:
data = _read_codex_tokens()
except AuthError:
except AuthError as exc:
read_error = exc
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
"codex_auth_missing_access_token",
"codex_auth_missing_refresh_token",
"codex_auth_invalid_shape",
}:
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
if imported:
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
else:
data = None
else:
data = None

if data is None:
pool_token = _pool_codex_access_token()
if pool_token:
base_url = (
Expand All @@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
"last_refresh": None,
"auth_mode": "chatgpt",
}
raise
if read_error is not None:
raise read_error
raise AuthError(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
)

tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"kenmege@yahoo.com": "Kenmege",
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
"barronlroth@gmail.com": "barronlroth",
Expand Down
1 change: 1 addition & 0 deletions tests/hermes_cli/test_auth_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkey
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home, access_token="")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))

with pytest.raises(AuthError) as exc:
resolve_codex_runtime_credentials()
Expand Down
208 changes: 208 additions & 0 deletions tests/hermes_cli/test_auth_codex_self_heal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).

Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
ones (e.g. 429 quota, where the stored token is still valid).
"""

import json

import pytest

import hermes_cli.auth as auth
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials

STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}


def test_self_heals_on_stale_refresh_token(monkeypatch):
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
saved = {}
fresh = {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"last_refresh": "2026-06-12T00:00:00Z",
}

def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)

monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))

out = _refresh_codex_auth_tokens(STALE, 20.0)

assert out["access_token"] == "fresh-access"
assert out["refresh_token"] == "fresh-refresh"
# the recovered token was persisted to the Hermes auth store
assert saved["access_token"] == "fresh-access"


def test_does_not_self_heal_on_rate_limit(monkeypatch):
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
import_calls = {"n": 0}

def _rate_limited(*_a, **_k):
raise AuthError(
"quota exhausted",
provider="openai-codex",
code="codex_rate_limited",
relogin_required=False,
)

def _import_spy():
import_calls["n"] += 1
return {"access_token": "should-not-be-used"}

monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)

with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)

assert ei.value.code == "codex_rate_limited"
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure


def test_reraises_when_codex_cli_token_absent(monkeypatch):
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""

def _reused(*_a, **_k):
raise AuthError(
"refresh token reused",
provider="openai-codex",
code="refresh_token_reused",
relogin_required=True,
)

monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)

with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)

assert ei.value.code == "refresh_token_reused"


def test_happy_path_unchanged(monkeypatch):
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
saved = {}
import_calls = {"n": 0}

def _import_spy():
import_calls["n"] += 1
return None

monkeypatch.setattr(
auth,
"refresh_codex_oauth_pure",
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))

out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)

assert out["access_token"] == "rotated"
assert out["refresh_token"] == "rotated-r"
assert saved["access_token"] == "rotated"
assert import_calls["n"] == 0 # happy path must not consult ~/.codex


def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
re-raise rather than persist a half-token that would break the next refresh."""
saved = {}

def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)

monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))

with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)

assert ei.value.code == "invalid_grant"
assert saved == {} # nothing was persisted


def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"last_refresh": "2026-06-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))

resolved = resolve_codex_runtime_credentials()

assert resolved["api_key"] == "fresh-access"
assert resolved["source"] == "hermes-auth-store"
stored = json.loads((hermes_home / "auth.json").read_text())
tokens = stored["providers"]["openai-codex"]["tokens"]
assert tokens["access_token"] == "fresh-access"
assert tokens["refresh_token"] == "fresh-refresh"


def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
"""Missing access_token must not be masked by a malformed Codex CLI import."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "fresh-only"},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))

with pytest.raises(AuthError) as ei:
resolve_codex_runtime_credentials()

assert ei.value.code == "codex_auth_missing_access_token"
Loading