diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 51c179b4075..365abd33375 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3312,16 +3312,38 @@ def _sync_codex_pool_entries( tokens: Dict[str, str], last_refresh: Optional[str], ) -> None: - """Mirror a fresh Codex re-auth into the credential_pool singleton entries. + """Mirror a fresh Codex re-auth into the credential_pool OAuth 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. + OAuth pair server-side, but pool entries keep 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``. + + What gets refreshed: + + * ``device_code`` — the singleton-seeded entry written by the device-code + OAuth flow when the user logged in via ``hermes setup`` / the model + picker. Always synced with the fresh tokens. + * ``manual:device_code`` — entries created by ``hermes auth add openai-codex`` + that use the same device-code OAuth mechanism. An interactive re-auth + proves the user owns the ChatGPT account, so it is safe (and expected) + to refresh these entries too. Without this, a user who once ran the + ``hermes auth add`` workaround for #33000 would silently leave that + manual entry stale on every subsequent re-auth, recreating the issue + reported in #33538. + + What does NOT get refreshed: + + * ``manual:api_key`` and any other non-device-code manual sources — those + are independent credentials (an explicit API key, a different ChatGPT + account, etc.) and must not be overwritten by a single re-auth. + + Error markers (``last_status``, ``last_error_*``) are also cleared on + every device-code-backed entry — even those whose tokens we did not + rewrite — so that an interactive re-auth gives every relevant pool entry + a fresh selection chance instead of leaving them marked unhealthy from a + pre-re-auth 401. """ access_token = tokens.get("access_token") if not access_token: @@ -3333,8 +3355,15 @@ def _sync_codex_pool_entries( entries = pool.get("openai-codex") if not isinstance(entries, list): return + # Sources whose tokens should be rewritten by a fresh Codex device-code + # OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally + # excluded — they represent independent credentials. + REFRESHABLE_SOURCES = {"device_code", "manual:device_code"} for entry in entries: - if not isinstance(entry, dict) or entry.get("source") != "device_code": + if not isinstance(entry, dict): + continue + source = entry.get("source") + if source not in REFRESHABLE_SOURCES: continue entry["access_token"] = access_token if refresh_token: diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 7b1bec33929..0d935eab39f 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -303,6 +303,88 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch): assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at" +def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch): + """Re-auth must also refresh ``manual:device_code`` pool entries. + + Regression for #33538: a user who hit #33000 before the #33164 fix landed + would have run ``hermes auth add openai-codex`` as a workaround, leaving + a pool entry with ``source="manual:device_code"``. On every subsequent + re-auth via setup/model picker, the singleton-seeded ``device_code`` entry + got refreshed but the ``manual:device_code`` entry stayed stale, recreating + the same 401 token_invalidated symptom that #33164 was supposed to fix. + + An interactive Codex device-code re-auth proves the user owns the ChatGPT + account, so it is safe to refresh every device-code-backed entry in the + pool — but NOT independent ``manual:api_key`` entries (separate accounts / + explicit API keys). + """ + 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": "seeded", + "source": "device_code", + "auth_type": "oauth", + "access_token": "old-at", + "refresh_token": "old-rt", + }, + { + "id": "auth-add", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "stale-manual-at", + "refresh_token": "stale-manual-rt", + "last_status": "exhausted", + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + { + "id": "api-key", + "source": "manual:api_key", + "auth_type": "api_key", + "access_token": "user-api-key", + }, + ], + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _save_codex_tokens({"access_token": "fresh-at", "refresh_token": "fresh-rt"}, + last_refresh="2026-05-28T00:00:00Z") + + auth = json.loads((hermes_home / "auth.json").read_text()) + pool = auth["credential_pool"]["openai-codex"] + + # Singleton-seeded device_code entry: refreshed and error markers cleared. + seeded = next(e for e in pool if e["source"] == "device_code") + assert seeded["access_token"] == "fresh-at" + assert seeded["refresh_token"] == "fresh-rt" + + # manual:device_code entry: ALSO refreshed (the new behavior). + manual_dc = next(e for e in pool if e["source"] == "manual:device_code") + assert manual_dc["access_token"] == "fresh-at" + assert manual_dc["refresh_token"] == "fresh-rt" + assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z" + assert manual_dc["last_status"] is None + assert manual_dc["last_error_code"] is None + assert manual_dc["last_error_reason"] is None + + # manual:api_key entry: untouched — independent credential. + api_key = next(e for e in pool if e["source"] == "manual:api_key") + assert api_key["access_token"] == "user-api-key" + assert "refresh_token" not in api_key or api_key.get("refresh_token") is None + + def test_import_codex_cli_tokens(tmp_path, monkeypatch): codex_home = tmp_path / "codex-cli" codex_home.mkdir(parents=True, exist_ok=True)