Compare commits

...

1 Commits

Author SHA1 Message Date
Ben
69f0df0402 fix: sync refreshed OAuth tokens from pool back to auth.json providers
After a pool-level refresh, the credential_pool entry had fresh tokens
but auth.json's providers section retained the pre-refresh state. On
the next load_pool(), _seed_from_singletons() would read that stale
state and upsert it back — potentially overwriting fresh tokens with
consumed/expired ones.

This affects all OAuth providers whose singleton lives in auth.json:

- Nous: providers.nous stores access_token, refresh_token, agent_key
- OpenAI Codex: providers.openai-codex.tokens stores access/refresh

(Anthropic is unaffected — its singletons live in separate credential
files that already have their own write-back paths.)

Adds _sync_device_code_entry_to_auth_store() which writes the refreshed
tokens back under the auth store lock. Called automatically after every
successful credential refresh.
2026-04-10 10:32:10 +10:00

View File

@@ -20,6 +20,7 @@ from hermes_cli.auth import (
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
KIMI_CODE_BASE_URL, KIMI_CODE_BASE_URL,
PROVIDER_REGISTRY, PROVIDER_REGISTRY,
_auth_store_lock,
_codex_access_token_is_expiring, _codex_access_token_is_expiring,
_decode_jwt_claims, _decode_jwt_claims,
_import_codex_cli_tokens, _import_codex_cli_tokens,
@@ -27,6 +28,8 @@ from hermes_cli.auth import (
_load_provider_state, _load_provider_state,
_resolve_kimi_base_url, _resolve_kimi_base_url,
_resolve_zai_base_url, _resolve_zai_base_url,
_save_auth_store,
_save_provider_state,
read_credential_pool, read_credential_pool,
write_credential_pool, write_credential_pool,
) )
@@ -479,6 +482,67 @@ class CredentialPool:
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc) logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
return entry 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.<id>`` 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]: def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token: if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
if force: if force:
@@ -612,6 +676,10 @@ class CredentialPool:
) )
self._replace_entry(entry, updated) self._replace_entry(entry, updated)
self._persist() 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 return updated
def _entry_needs_refresh(self, entry: PooledCredential) -> bool: def _entry_needs_refresh(self, entry: PooledCredential) -> bool: