From dfde4058cf44c1cfd55c7c2bc1e89b648a2ea4d7 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Thu, 9 Apr 2026 18:04:09 -0700 Subject: [PATCH] fix: sync refreshed OAuth tokens from pool back to auth.json providers --- agent/credential_pool.py | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index a17d71ba5e..d89a7ebce7 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -20,6 +20,7 @@ from hermes_cli.auth import ( DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, KIMI_CODE_BASE_URL, PROVIDER_REGISTRY, + _auth_store_lock, _codex_access_token_is_expiring, _decode_jwt_claims, _import_codex_cli_tokens, @@ -27,6 +28,8 @@ from hermes_cli.auth import ( _load_provider_state, _resolve_kimi_base_url, _resolve_zai_base_url, + _save_auth_store, + _save_provider_state, read_credential_pool, write_credential_pool, ) @@ -479,6 +482,67 @@ class CredentialPool: logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc) return entry + def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None: + """Write refreshed pool entry tokens back to auth.json providers. + + After a pool-level refresh, the pool entry has fresh tokens but + auth.json's ``providers.`` still holds the pre-refresh state. + On the next ``load_pool()``, ``_seed_from_singletons()`` reads that + stale state and can overwrite the fresh pool entry — potentially + re-seeding a consumed single-use refresh token. + + Applies to any OAuth provider whose singleton lives in auth.json + (currently Nous and OpenAI Codex). + """ + if entry.source != "device_code": + return + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + if self.provider == "nous": + state = _load_provider_state(auth_store, "nous") + if state is None: + return + state["access_token"] = entry.access_token + if entry.refresh_token: + state["refresh_token"] = entry.refresh_token + if entry.expires_at: + state["expires_at"] = entry.expires_at + if entry.agent_key: + state["agent_key"] = entry.agent_key + if entry.agent_key_expires_at: + state["agent_key_expires_at"] = entry.agent_key_expires_at + for extra_key in ("obtained_at", "expires_in", "agent_key_id", + "agent_key_expires_in", "agent_key_reused", + "agent_key_obtained_at"): + val = entry.extra.get(extra_key) + if val is not None: + state[extra_key] = val + if entry.inference_base_url: + state["inference_base_url"] = entry.inference_base_url + _save_provider_state(auth_store, "nous", state) + + elif self.provider == "openai-codex": + state = _load_provider_state(auth_store, "openai-codex") + if not isinstance(state, dict): + return + tokens = state.get("tokens") + if not isinstance(tokens, dict): + return + tokens["access_token"] = entry.access_token + if entry.refresh_token: + tokens["refresh_token"] = entry.refresh_token + if entry.last_refresh: + state["last_refresh"] = entry.last_refresh + _save_provider_state(auth_store, "openai-codex", state) + + else: + return + + _save_auth_store(auth_store) + except Exception as exc: + logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc) + def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]: if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token: if force: @@ -612,6 +676,10 @@ class CredentialPool: ) self._replace_entry(entry, updated) self._persist() + # Sync refreshed tokens back to auth.json providers so that + # _seed_from_singletons() on the next load_pool() sees fresh state + # instead of re-seeding stale/consumed tokens. + self._sync_device_code_entry_to_auth_store(updated) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: