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
51 changes: 37 additions & 14 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,23 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
return None


def _codex_auth_error_allows_external_recovery(error: AuthError) -> bool:
return bool(getattr(error, "code", None) in {
"codex_auth_missing",
"invalid_grant",
"invalid_token",
})


def _recover_codex_auth_from_cli(*, reason: str = "") -> bool:
cli_tokens = _import_codex_cli_tokens()
if not cli_tokens:
return False
logger.warning("Recovering Codex credentials from local Codex auth store (reason=%s)", reason or "unknown")
_save_codex_tokens(cli_tokens)
return True


def resolve_codex_runtime_credentials(
*,
force_refresh: bool = False,
Expand All @@ -1524,22 +1541,13 @@ def resolve_codex_runtime_credentials(
try:
data = _read_codex_tokens()
except AuthError as orig_err:
# Only attempt migration when there are NO tokens stored at all
# (code == "codex_auth_missing"), not when tokens exist but are invalid.
if orig_err.code != "codex_auth_missing":
if not _codex_auth_error_allows_external_recovery(orig_err):
raise

# Migration: user had Codex as active provider with old storage (~/.codex/).
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store")
print("⚠️ Migrating Codex credentials to Hermes's own auth store.")
print(" This avoids conflicts with Codex CLI and VS Code.")
print(" Run `hermes auth` to create a fully independent session.\n")
_save_codex_tokens(cli_tokens)
data = _read_codex_tokens()
else:
recovered = _recover_codex_auth_from_cli(reason=orig_err.code or "initial_read_failed")
if not recovered:
raise
data = _read_codex_tokens()
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20"))
Expand All @@ -1559,7 +1567,22 @@ 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)
try:
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
except AuthError as refresh_err:
if not _codex_auth_error_allows_external_recovery(refresh_err):
raise
recovered = _recover_codex_auth_from_cli(reason=refresh_err.code or "refresh_failed")
if not recovered:
raise
data = _read_codex_tokens(_lock=False)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
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()

base_url = (
Expand Down
59 changes: 59 additions & 0 deletions tests/hermes_cli/test_auth_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest
import yaml
import hermes_cli.auth as auth

from hermes_cli.auth import (
AuthError,
Expand Down Expand Up @@ -129,6 +130,64 @@ def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
assert resolve_provider("openai-codex") == "openai-codex"


def test_resolve_codex_runtime_credentials_recovers_from_cli_on_refresh_failure(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex-cli"
_setup_hermes_auth(hermes_home, access_token="hermes-at", refresh_token="hermes-rt")
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "auth.json").write_text(
json.dumps({"tokens": {"access_token": "cli-at", "refresh_token": "cli-rt"}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))

calls = {"count": 0}

def _fake_refresh(tokens, timeout_seconds):
calls["count"] += 1
if calls["count"] == 1:
raise AuthError(
"refresh token revoked",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
assert tokens["access_token"] == "cli-at"
assert tokens["refresh_token"] == "cli-rt"
auth._save_codex_tokens({"access_token": "validated-at", "refresh_token": "validated-rt"})
return {"access_token": "validated-at", "refresh_token": "validated-rt"}

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

resolved = resolve_codex_runtime_credentials(force_refresh=True, refresh_if_expiring=False)

assert resolved["api_key"] == "validated-at"
assert calls["count"] == 2



def test_resolve_codex_runtime_credentials_raises_original_error_when_cli_recovery_missing(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home, access_token="hermes-at", refresh_token="hermes-rt")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex-home"))

def _fake_refresh(tokens, timeout_seconds):
raise AuthError(
"refresh token revoked",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)

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

with pytest.raises(AuthError) as exc:
resolve_codex_runtime_credentials(force_refresh=True, refresh_if_expiring=False)

assert exc.value.code == "invalid_grant"


def test_save_codex_tokens_roundtrip(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
Expand Down
Loading