From c4003df8cd7f4472c98e33df4ad2413af44a5e7f Mon Sep 17 00:00:00 2001 From: Kennedy Umege Date: Fri, 12 Jun 2026 23:47:15 +0100 Subject: [PATCH 1/2] fix(auth): self-heal Codex refresh_token rotation by reimporting from ~/.codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes keeps its own copy of the Codex OAuth token per profile and at the 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 (invalid_grant / refresh_token_reused / 401). Today that surfaces as a hard 401 on the turn — idle profiles and desktop sessions 401 "token_expired" until a manual re-auth — even though ~/.codex/auth.json holds a fresh token. _refresh_codex_auth_tokens now falls back to _import_codex_cli_tokens() (the canonical Codex CLI store) when the stored refresh_token is rejected, adopts and persists the fresh token, and lets the in-flight retry succeed. This complements PR #6525 (force relogin on 401/403): we attempt automatic recovery before surfacing a relogin prompt. Transient failures (e.g. 429 quota, relogin_required=False) are never self-healed — the stored token is still valid there — so they re-raise unchanged, and the happy path is untouched. Adds tests/hermes_cli/test_auth_codex_self_heal.py covering: self-heal on invalid_grant, no self-heal on 429 quota, re-raise when ~/.codex is absent, and happy-path-unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- hermes_cli/auth.py | 36 +++++- tests/hermes_cli/test_auth_codex_self_heal.py | 120 ++++++++++++++++++ 2 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_auth_codex_self_heal.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index a65e9ea78b84..c8e03b1104d5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3660,11 +3660,37 @@ 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 = _import_codex_cli_tokens() + if not (imported and str(imported.get("access_token", "") or "").strip()): + raise + logger.info( + "Codex refresh_token rejected (%s); recovered from ~/.codex/auth.json.", + getattr(exc, "code", None) or "auth_error", + ) + _save_codex_tokens(imported) + return dict(imported) + updated_tokens = dict(tokens) updated_tokens["access_token"] = refreshed["access_token"] updated_tokens["refresh_token"] = refreshed["refresh_token"] diff --git a/tests/hermes_cli/test_auth_codex_self_heal.py b/tests/hermes_cli/test_auth_codex_self_heal.py new file mode 100644 index 000000000000..699f77acfcce --- /dev/null +++ b/tests/hermes_cli/test_auth_codex_self_heal.py @@ -0,0 +1,120 @@ +"""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 pytest + +import hermes_cli.auth as auth +from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens + +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 From adfab1126d02530f63b367993b0ebb45d0b0d2ec Mon Sep 17 00:00:00 2001 From: Kennedy Umege Date: Sat, 13 Jun 2026 00:35:41 +0100 Subject: [PATCH 2/2] review: validate refresh_token, path-agnostic recovery log, map author email Addresses PR review feedback: - Validate refresh_token (not only access_token) before persisting the re-imported Codex token, so a half-token payload can't silently break the next refresh cycle. - Make the recovery log path-agnostic ("Codex CLI auth.json") since _import_codex_cli_tokens can read $CODEX_HOME, not only ~/.codex. - Add regression test: relogin-required + imported token missing refresh_token -> re-raise and persist nothing. - Map kenmege@yahoo.com -> Kenmege in scripts/release.py AUTHOR_MAP (fixes the check-attribution job). Co-Authored-By: Claude Opus 4.8 (1M context) --- hermes_cli/auth.py | 10 ++++++-- scripts/release.py | 1 + tests/hermes_cli/test_auth_codex_self_heal.py | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c8e03b1104d5..af21d050fdac 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3682,10 +3682,16 @@ def _refresh_codex_auth_tokens( if not getattr(exc, "relogin_required", False): raise imported = _import_codex_cli_tokens() - if not (imported and str(imported.get("access_token", "") or "").strip()): + # 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() + ): raise logger.info( - "Codex refresh_token rejected (%s); recovered from ~/.codex/auth.json.", + "Codex refresh_token rejected (%s); recovered from Codex CLI auth.json.", getattr(exc, "code", None) or "auth_error", ) _save_codex_tokens(imported) diff --git a/scripts/release.py b/scripts/release.py index 9d2e275d4b51..63534e15d908 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", diff --git a/tests/hermes_cli/test_auth_codex_self_heal.py b/tests/hermes_cli/test_auth_codex_self_heal.py index 699f77acfcce..583bb3f381c8 100644 --- a/tests/hermes_cli/test_auth_codex_self_heal.py +++ b/tests/hermes_cli/test_auth_codex_self_heal.py @@ -118,3 +118,27 @@ def _import_spy(): 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