From 0ab4cdc27df97d14e9df769532e65282d20dd7b1 Mon Sep 17 00:00:00 2001 From: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:24:21 +0700 Subject: [PATCH] fix(codex): adopt refresh_token from auth.json even without access_token (#70097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the openai-codex credential pool recovery path: Defect 1 — adoption path silently no-ops when store_access is empty _sync_codex_entry_from_auth_store() skipped adoption when the auth store had no access_token (only last_refresh). When another process rotated the token pair, the stale profile's entry kept the consumed refresh_token and replayed it, getting refresh_token_reused and going terminally DEAD. Fix: also adopt when store_refresh differs from entry_refresh, even when store_access is empty. Keep the entry's existing access_token in that case (store_access or entry.access_token). Defect 2 — false 'auth refreshed' success log _try_refresh_codex_client_credentials() returned True whenever resolve_codex_runtime_credentials() returned any non-empty credentials, including the same stale token when the underlying refresh failed. The conversation loop then logged 'auth refreshed after 401' right before the retry failed with the identical token_expired. Fix: compare the access token before/after the refresh. If unchanged, return False so the 401-retry path logs the truth. Fixes #70097 --- agent/credential_pool.py | 26 +++++++++++++++++++++++++- run_agent.py | 15 +++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) 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