fix(codex): adopt refresh_token from auth.json even without access_token (#70097)

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
This commit is contained in:
JonthanaHanh
2026-07-23 21:24:21 +07:00
committed by kshitij
parent fb6446fc9e
commit 0ab4cdc27d
2 changed files with 40 additions and 1 deletions

View File

@@ -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,

View File

@@ -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