From 84b6ede05fcfdf1d1064c20c7035399c4c0f704f Mon Sep 17 00:00:00 2001 From: Kennedy Umege Date: Fri, 12 Jun 2026 23:47:15 +0100 Subject: [PATCH 1/3] 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 a65e9ea78b841..c8e03b1104d5f 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 0000000000000..699f77acfcceb --- /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 38807676c2a7c3d4c30de012518b350655e2a832 Mon Sep 17 00:00:00 2001 From: Kennedy Umege Date: Sat, 13 Jun 2026 00:35:41 +0100 Subject: [PATCH 2/3] 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 c8e03b1104d5f..af21d050fdac1 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 77b7eef9aeb14..ca8a9c422bb57 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 699f77acfcceb..583bb3f381c8c 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 From e58184d9827b65c673351eb590986d0581913042 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 13 Jun 2026 05:02:45 -0700 Subject: [PATCH 3/3] fix(auth): self-heal missing Codex access tokens Recover Codex singleton auth entries that have a refresh token but no access token by adopting a valid Codex CLI token pair, matching the cron-time failure mode before falling back to the credential pool. --- hermes_cli/auth.py | 62 ++++++++++++----- tests/hermes_cli/test_auth_codex_provider.py | 1 + tests/hermes_cli/test_auth_codex_self_heal.py | 66 ++++++++++++++++++- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index af21d050fdac1..38bcab929072d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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, @@ -3681,21 +3697,12 @@ def _refresh_codex_auth_tokens( # we never self-heal those and re-raise unchanged. if not getattr(exc, "relogin_required", False): raise - 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() - ): - raise - logger.info( - "Codex refresh_token rejected (%s); recovered from Codex CLI auth.json.", - getattr(exc, "code", None) or "auth_error", + imported = _recover_codex_tokens_from_cli( + f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}" ) - _save_codex_tokens(imported) - return dict(imported) + if not imported: + raise + return imported updated_tokens = dict(tokens) updated_tokens["access_token"] = refreshed["access_token"] @@ -3756,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 = ( @@ -3773,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() diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index cb85cf6818ed2..2ce2907650da9 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -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() diff --git a/tests/hermes_cli/test_auth_codex_self_heal.py b/tests/hermes_cli/test_auth_codex_self_heal.py index 583bb3f381c8c..93810c717740e 100644 --- a/tests/hermes_cli/test_auth_codex_self_heal.py +++ b/tests/hermes_cli/test_auth_codex_self_heal.py @@ -10,10 +10,12 @@ 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 +from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"} @@ -142,3 +144,65 @@ def _rejected(*_a, **_k): 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"