Skip to content
Closed
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
42 changes: 37 additions & 5 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3660,11 +3660,43 @@ 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()
# 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
Comment on lines +3684 to +3692
logger.info(
"Codex refresh_token rejected (%s); recovered from Codex CLI auth.json.",
getattr(exc, "code", None) or "auth_error",
)
Comment on lines +3693 to +3696
_save_codex_tokens(imported)
return dict(imported)
Comment on lines +3697 to +3698

updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
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
144 changes: 144 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,144 @@
"""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


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
Loading