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
65 changes: 62 additions & 3 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1317,7 +1317,7 @@ def refresh_codex_oauth_pure(
if response.status_code != 200:
code = "codex_refresh_failed"
message = f"Codex token refresh failed with status {response.status_code}."
relogin_required = False
relogin_required = response.status_code == 401
try:
err = response.json()
if isinstance(err, dict):
Expand Down Expand Up @@ -1431,6 +1431,52 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
return None


def _recover_codex_tokens_from_cli(
current_tokens: Dict[str, str],
*,
refresh_skew_seconds: int,
failure: Exception,
) -> Optional[Dict[str, str]]:
"""Adopt a still-valid Codex CLI session when Hermes's stored session dies.

Hermes keeps its own auth store to avoid refresh-token rotation conflicts with
the Codex CLI. In practice, users can still end up with a stale Hermes session
while ``~/.codex/auth.json`` has a newer valid access token. When that happens,
recover by importing the Codex CLI tokens into Hermes's store instead of
forcing a manual logout/re-login cycle.
"""
cli_tokens = _import_codex_cli_tokens()
if not cli_tokens:
return None

cli_access_token = str(cli_tokens.get("access_token", "") or "").strip()
cli_refresh_token = str(cli_tokens.get("refresh_token", "") or "").strip()
if not cli_access_token or not cli_refresh_token:
return None
if (
cli_access_token == str(current_tokens.get("access_token", "") or "").strip()
and cli_refresh_token == str(current_tokens.get("refresh_token", "") or "").strip()
):
return None
if _codex_access_token_is_expiring(cli_access_token, refresh_skew_seconds):
return None

logger.warning(
"Recovering Codex credentials from ~/.codex/auth.json after Hermes refresh failed: %s",
failure,
)
_save_codex_tokens(
{
"access_token": cli_access_token,
"refresh_token": cli_refresh_token,
}
)
return {
"access_token": cli_access_token,
"refresh_token": cli_refresh_token,
}


def resolve_codex_runtime_credentials(
*,
force_refresh: bool = False,
Expand Down Expand Up @@ -1476,8 +1522,21 @@ def resolve_codex_runtime_credentials(
should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)

if should_refresh:
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
access_token = str(tokens.get("access_token", "") or "").strip()
try:
tokens=_refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
except AuthError as exc:
if not exc.relogin_required:
raise
recovered_tokens=_recover_codex_tokens_from_cli(
tokens,
refresh_skew_seconds=refresh_skew_seconds,
failure=exc,
)
if recovered_tokens is None:
raise
tokens=recovered_tokens
data = _read_codex_tokens(_lock=False)
access_token=str(tokens.get("access_token", "") or "").strip()

base_url = (
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
Expand Down
113 changes: 113 additions & 0 deletions tests/hermes_cli/test_auth_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AuthError,
DEFAULT_CODEX_BASE_URL,
PROVIDER_REGISTRY,
_refresh_codex_auth_tokens,
_read_codex_tokens,
_save_codex_tokens,
_import_codex_cli_tokens,
Expand Down Expand Up @@ -122,6 +123,87 @@ def _fake_refresh(tokens, timeout_seconds):
assert resolved["api_key"] == "access-forced"


def test_resolve_codex_runtime_credentials_recovers_from_cli_when_hermes_refresh_fails(
tmp_path, monkeypatch
):
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex-cli"
expiring_token=_jwt_with_exp(int(time.time()) - 10)
cli_token=_jwt_with_exp(int(time.time()) + 3600)
_setup_hermes_auth(hermes_home, access_token=expiring_token, refresh_token="hermes-refresh")
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "auth.json").write_text(
json.dumps(
{
"tokens": {
"access_token": cli_token,
"refresh_token": "cli-refresh",
}
}
)
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))

def _fake_refresh(tokens, timeout_seconds):
raise AuthError(
"Codex token refresh failed with status 401.",
provider="openai-codex",
code="codex_refresh_failed",
relogin_required=True,
)

monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)

resolved = resolve_codex_runtime_credentials()

assert resolved["api_key"] == cli_token
saved = _read_codex_tokens()
assert saved["tokens"]["access_token"] == cli_token
assert saved["tokens"]["refresh_token"] == "cli-refresh"


def test_resolve_codex_runtime_credentials_does_not_recover_from_cli_on_non_relogin_failure(
tmp_path, monkeypatch
):
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex-cli"
expiring_token=_jwt_with_exp(int(time.time()) - 10)
cli_token=_jwt_with_exp(int(time.time()) + 3600)
_setup_hermes_auth(hermes_home, access_token=expiring_token, refresh_token="hermes-refresh")
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "auth.json").write_text(
json.dumps(
{
"tokens": {
"access_token": cli_token,
"refresh_token": "cli-refresh",
}
}
)
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))

def _fake_refresh(tokens, timeout_seconds):
raise AuthError(
"Codex refresh timed out.",
provider="openai-codex",
code="codex_refresh_timeout",
relogin_required=False,
)

monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)

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

assert exc.value.code == "codex_refresh_timeout"
saved = _read_codex_tokens()
assert saved["tokens"]["access_token"] == expiring_token
assert saved["tokens"]["refresh_token"] == "hermes-refresh"


def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
Expand Down Expand Up @@ -190,3 +272,34 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
assert creds["source"] == "hermes-auth-store"
assert creds["provider"] == "openai-codex"
assert creds["base_url"] == DEFAULT_CODEX_BASE_URL


def test_refresh_codex_auth_tokens_marks_401_as_relogin_required(monkeypatch):
class _FakeResponse:
status_code = 401

def json(self):
raise ValueError("no json")

class _FakeClient:
def __init__(self, *args, **kwargs):
pass

def __enter__(self):
return self

def __exit__(self, exc_type, exc, tb):
return False

def post(self, *args, **kwargs):
return _FakeResponse()

monkeypatch.setattr("hermes_cli.auth.httpx.Client", _FakeClient)

with pytest.raises(AuthError) as exc:
_refresh_codex_auth_tokens(
{"access_token": "access-old", "refresh_token": "refresh-old"},
5.0,
)
assert exc.value.code == "codex_refresh_failed"
assert exc.value.relogin_required is True