diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 715f8b8b17..54e86d61f1 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -811,19 +811,43 @@ class CredentialPool: # Adopt auth.json tokens when either side differs. Codex refresh # tokens are single-use too, so a fresh refresh_token from # another process means our entry's pair is consumed/stale. + # + # Also adopt when the store has a refresh_token but no + # access_token — another process may have rotated the pair + # and the store entry's access_token was already consumed; + # the important signal is the refresh_token difference. entry_access = entry.access_token or "" entry_refresh = entry.refresh_token or "" + should_adopt = False if store_access and ( store_access != entry_access or (store_refresh and store_refresh != entry_refresh) ): + should_adopt = True + elif ( + store_refresh + and store_refresh != entry_refresh + and not store_access + ): + # Store has only a refresh_token (no access_token) — + # another process rotated the pair. Adopt the + # refresh_token so we don't replay the consumed one. + logger.info( + "Pool entry %s: auth.json has newer refresh_token " + "but no access_token; adopting refresh_token to " + "avoid replaying consumed token", + entry.id, + ) + should_adopt = True + + if should_adopt: logger.debug( "Pool entry %s: syncing Codex tokens from auth.json " "(refreshed by another process)", entry.id, ) field_updates: Dict[str, Any] = { - "access_token": store_access, + "access_token": store_access or entry.access_token, "refresh_token": store_refresh or entry.refresh_token, "last_status": None, "last_status_at": None, diff --git a/run_agent.py b/run_agent.py index 0376c4b16c..5ad73f772c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5130,10 +5130,12 @@ class AIAgent: if self.provider == "openai-codex": from hermes_cli.auth import resolve_codex_runtime_credentials + old_key = str(self.api_key or "").strip() creds = resolve_codex_runtime_credentials(force_refresh=force) else: from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + old_key = str(self.api_key or "").strip() creds = resolve_xai_oauth_runtime_credentials(force_refresh=force) except Exception as exc: logger.debug("%s credential refresh failed: %s", self.provider, exc) @@ -5146,6 +5148,19 @@ class AIAgent: if not isinstance(base_url, str) or not base_url.strip(): return False + # Defect 2 fix: return False when no NEW token was actually minted. + # resolve_codex_runtime_credentials returns the same stale token + # when the underlying refresh fails (failure is debug-only). + # Comparing the access token (api_key) before/after detects this. + new_key = api_key.strip() + if old_key and new_key == old_key: + logger.debug( + "%s credential refresh returned the same token; " + "refresh likely failed silently", + self.provider, + ) + return False + self.api_key = api_key.strip() self.base_url = base_url.strip().rstrip("/") self._client_kwargs["api_key"] = self.api_key