Skip to content
Merged
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
43 changes: 43 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3223,6 +3223,48 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
}


def _sync_codex_pool_entries(
auth_store: Dict[str, Any],
tokens: Dict[str, str],
last_refresh: Optional[str],
) -> None:
"""Mirror a fresh Codex re-auth into the credential_pool singleton entries.

The runtime selects credentials from ``credential_pool.openai-codex``, not
from ``providers.openai-codex.tokens``. A re-auth invalidates the prior
OAuth pair server-side, but the pool's ``device_code`` entry keeps holding
the now-consumed refresh token plus any stale error markers — so the next
request spends a dead token and gets a 401 ``token_invalidated``. Update
the singleton-seeded entries in lockstep with the provider tokens and clear
the error state so the fresh credentials take effect immediately. Manual
(``manual:*``) entries are independent credentials and are left untouched.
"""
access_token = tokens.get("access_token")
if not access_token:
return
refresh_token = tokens.get("refresh_token")
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
return
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict) or entry.get("source") != "device_code":
continue
entry["access_token"] = access_token
if refresh_token:
entry["refresh_token"] = refresh_token
if last_refresh:
entry["last_refresh"] = last_refresh
entry["last_status"] = None
entry["last_status_at"] = None
entry["last_error_code"] = None
entry["last_error_reason"] = None
entry["last_error_message"] = None
entry["last_error_reset_at"] = None


def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
if last_refresh is None:
Expand All @@ -3234,6 +3276,7 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
_save_auth_store(auth_store)


Expand Down
67 changes: 67 additions & 0 deletions tests/hermes_cli/test_auth_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,73 @@ def test_save_codex_tokens_roundtrip(tmp_path, monkeypatch):
assert data["tokens"]["refresh_token"] == "rt456"


def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
"""Re-auth must update the credential_pool device_code entry, not just providers.

Regression for #33000: the runtime selects from credential_pool, so a
re-auth that only refreshed providers.openai-codex.tokens left the pool
holding a consumed refresh token and stale error markers, causing an
immediate 401 token_invalidated on the next request.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "abc123",
"source": "device_code",
"auth_type": "oauth",
"access_token": "old-at",
"refresh_token": "old-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
"last_error_reset_at": 9999999999,
},
{
"id": "manual1",
"source": "manual:codex",
"auth_type": "oauth",
"access_token": "manual-at",
"refresh_token": "manual-rt",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))

_save_codex_tokens({"access_token": "new-at", "refresh_token": "new-rt"},
last_refresh="2026-05-27T00:00:00Z")

auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "new-at"
assert seeded["refresh_token"] == "new-rt"
assert seeded["last_refresh"] == "2026-05-27T00:00:00Z"
assert seeded["last_status"] is None
assert seeded["last_error_code"] is None
assert seeded["last_error_reason"] is None
assert seeded["last_error_reset_at"] is None

# Manual entries are independent credentials and must not be overwritten.
manual = next(e for e in pool if e["source"] == "manual:codex")
assert manual["access_token"] == "manual-at"
assert manual["refresh_token"] == "manual-rt"

# Provider singleton is updated too.
assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at"


def test_import_codex_cli_tokens(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)
Expand Down
Loading