From 2bbd53493d3b2a739822fd1ca8264ce05319f4aa Mon Sep 17 00:00:00 2001 From: konsisumer Date: Wed, 27 May 2026 09:02:43 +0200 Subject: [PATCH] fix(cli): sync credential_pool on Codex re-auth Codex re-auth via `hermes setup` / `hermes model` wrote fresh OAuth tokens to providers.openai-codex.tokens but left the credential_pool device_code entry holding the consumed refresh token and stale error markers. Since the runtime selects from the pool, the next request spent a dead token and got a 401 token_invalidated. Update the singleton-seeded pool entries in lockstep and clear their error state. Fixes #33000 --- hermes_cli/auth.py | 43 +++++++++++++ tests/hermes_cli/test_auth_codex_provider.py | 67 ++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e69d12913d8..21aa566f9e9 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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: @@ -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) diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index ad5ce40f3db..47dfc4c0843 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -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)