Files
hermes-agent/agent/credential_pool.py
2026-09-27 18:37:01 +05:30

3114 lines
148 KiB
Python

"""Persistent multi-credential pool for same-provider failover."""
from __future__ import annotations
from agent.credential_pool_admin import CredentialPoolAdminMixin
from agent.credential_pool_model_cooldowns import CredentialPoolModelCooldownMixin, model_cooldown_until
import logging
import os
import random
import threading
import time
import uuid
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import load_env
from agent.secret_scope import get_secret as _get_secret, get_secret_str
from agent.retry_utils import reset_delay_from_message
from hermes_cli.auth_plugin_providers import plugin_refresh_hook
from agent.credential_pool_plugin import apply_plugin_refresh_result, recover_failed_plugin_refresh
from agent.credential_persistence import (
fingerprint_secret_value,
is_borrowed_credential_source,
sanitize_borrowed_credential_payload,
)
import hermes_cli.auth as auth_mod
from hermes_cli.auth import (
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
PROVIDER_REGISTRY,
SINGLE_USE_REFRESH_POOL_PROVIDERS,
_auth_store_lock,
_codex_access_token_is_expiring,
_decode_jwt_claims,
_global_auth_file_path,
_load_auth_store,
_load_provider_state,
_load_provider_state_with_source,
_resolve_kimi_base_url,
_resolve_zai_base_url,
_same_path,
_save_auth_store,
_save_provider_state,
_store_provider_state,
read_credential_pool,
write_credential_pool,
)
logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]:
"""Load config.yaml read-only, returning None on any error.
``load_config_readonly()`` skips the deepcopy ``load_config()`` pays per
call; the picker calls ``load_pool()`` once per provider row, which made
that copy the dominant cost of ``model.options``.
"""
try:
from hermes_cli.config import load_config_readonly
return load_config_readonly()
except Exception:
return None
def _is_source_suppressed_fn() -> Callable[[str, str], bool]:
"""``hermes_cli.auth.is_source_suppressed`` (late-bound), or an always-False stub."""
try:
from hermes_cli.auth import is_source_suppressed
return is_source_suppressed
except ImportError:
return lambda _p, _s: False
# --- Status and type constants ---
STATUS_OK = "ok"
STATUS_EXHAUSTED = "exhausted"
# Terminal failure — the credential will never recover on its own (upstream
# ``token_invalidated`` / ``token_revoked``). DEAD entries are excluded from
# rotation unconditionally and only clear when an explicit write-side sync
# (e.g. ``_save_codex_tokens`` after a fresh device-code login) rewrites tokens.
STATUS_DEAD = "dead"
# OAuth error reasons that mean the credential is permanently invalid
# server-side (OpenAI Codex, Anthropic, xAI, Google OAuth, RFC 6749/6750/7009).
_TERMINAL_AUTH_REASONS = frozenset({
"token_invalidated",
"token_revoked",
"invalid_token",
"invalid_grant",
"unauthorized_client",
"refresh_token_reused", # single-use refresh token consumed by another process
})
# Locally generated terminal reason (no HTTP status): a refresh POST rotated a
# single-use pair but the replacement never reached its authoritative store, so
# the pre-rotation token still on disk is already spent. Kept out of
# _TERMINAL_AUTH_REASONS (upstream 401 reasons) and handled explicitly.
CREDENTIAL_PERSIST_FAILED_REASON = "credential_persist_failed"
# DEAD ``manual:*`` entries are pruned after this quiet window — they have no
# singleton to re-seed from and the user can re-add via ``hermes auth add``.
# Singleton-seeded entries (device_code, claude_code) are NOT pruned because
# ``_seed_from_singletons`` would re-create them from the same stale tokens.
DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60
AUTH_TYPE_OAUTH = "oauth"
AUTH_TYPE_API_KEY = "api_key"
SOURCE_MANUAL = "manual"
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"
STRATEGY_FILL_FIRST = "fill_first"
STRATEGY_ROUND_ROBIN = "round_robin"
STRATEGY_RANDOM = "random"
STRATEGY_LEAST_USED = "least_used"
SUPPORTED_POOL_STRATEGIES = {
STRATEGY_FILL_FIRST,
STRATEGY_ROUND_ROBIN,
STRATEGY_RANDOM,
STRATEGY_LEAST_USED,
}
# Cooldowns before retrying an exhausted credential. Transient 401s cool down
# briefly so single-key setups recover; 429/402/other take an hour.
# Provider-supplied reset_at timestamps override these defaults.
EXHAUSTED_TTL_401_SECONDS = 5 * 60
EXHAUSTED_TTL_429_SECONDS = 60 * 60
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60
# When the offending key is the sole non-DEAD entry, an hour-long bench means
# an hour of hard failures. Throttles (429/403/5xx) reset in seconds, so a sole
# credential cools down briefly instead.
EXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS = 60
# ``FailoverReason.billing`` as a bare string: the pool persists classified
# failure semantics to JSON and must not import the classifier.
FAILURE_REASON_BILLING = "billing"
# Billing verdict resting on an ambiguous body (#82154): Anthropic's "out of
# extra usage" 400 is returned both for genuine overage and for a server-side
# content-filter rejection, which leaves the credential healthy. Unverified
# billing gets the short transient cooldown; genuine depletion re-latches.
FAILURE_REASON_BILLING_UNVERIFIED = "billing_unverified"
# Throttle window for the "no available entries" INFO line. Selection runs on
# every model call; on Windows several processes share one rotating log behind
# a cross-process lock, and per-selection logging stormed that lock, pegged a
# core, and stalled the event loop (Desktop backend readiness timeouts).
# Credential selection runs on a hot path (every model call, plus auxiliary tasks like
# compression/moa/titles), so when a pool is empty or fully exhausted the un-throttled log fires on *every*
# selection. On Windows several Hermes processes share one rotating log guarded by concurrent-log-handler's
# cross-process lock; that per-selection volume storms the lock (``RuntimeError: Cannot acquire lock after
# 20 attempts``), pegs a core, and stalls the asyncio event loop long enough to fail the Desktop backend
# readiness handshake ("Timed out connecting to Hermes backend after 15000ms"). Logging the condition at
# most once per window preserves the signal while removing the storm — same class of fix as the warn-once
# dedup in #58265.
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS = 60.0
# Pool key prefix for custom OpenAI-compatible endpoints: all share
# provider='custom' but are keyed 'custom:<normalized_name>'.
CUSTOM_POOL_PREFIX = "custom:"
# Fields only round-tripped through JSON — never used for logic as attributes.
_EXTRA_KEYS = frozenset({
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
"agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint",
# Nous guest identity (``auth_method: anonymous``): the anon_ credential is the refresh material.
"auth_method", "account_tier", "anon_token", "user_id", "org_id",
# Classified failure semantics for the last exhaustion (agent/error_classifier.py).
# Providers return 403 for both an edge throttle and a spending limit, so the
# raw status cannot size a cooldown; persisted so a restart doesn't downgrade
# a billing bench to a 60s transient cooldown.
"failure_reason",
})
# Nous singleton metadata mirrored between auth.json state and ``entry.extra``.
_NOUS_EXTRA_STATE_KEYS = (
"obtained_at", "expires_in", "agent_key_id",
"agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at",
"auth_method", "account_tier", "anon_token", "user_id", "org_id",
)
# ``replace(entry, **_CLEAR_STATUS)`` returns an entry with no error state.
_CLEAR_STATUS: Dict[str, Any] = {
"last_status": None,
"last_status_at": None,
"last_error_code": None,
"last_error_reason": None,
"last_error_message": None,
"last_error_reset_at": None,
}
_MARK_OK: Dict[str, Any] = {**_CLEAR_STATUS, "last_status": STATUS_OK}
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
if provider == "anthropic" and isinstance(token, str) and token.startswith("sk-ant-oat"):
return AUTH_TYPE_OAUTH
return str(auth_type or AUTH_TYPE_API_KEY)
@dataclass
class PooledCredential:
provider: str
id: str
label: str
auth_type: str
priority: int
source: str
access_token: str
refresh_token: Optional[str] = None
last_status: Optional[str] = None
last_status_at: Optional[float] = None
last_error_code: Optional[int] = None
last_error_reason: Optional[str] = None
last_error_message: Optional[str] = None
last_error_reset_at: Optional[float] = None
# Epoch of the last deliberate ``hermes auth reset`` of this entry. Sticky: a later exhaustion
# stamps a newer ``last_status_at``, so "reset postdates status" stays decidable across processes.
status_cleared_at: Optional[float] = None
base_url: Optional[str] = None
expires_at: Optional[str] = None
expires_at_ms: Optional[int] = None
last_refresh: Optional[str] = None
inference_base_url: Optional[str] = None
agent_key: Optional[str] = None
agent_key_expires_at: Optional[str] = None
request_count: int = 0
# A provider may rate-limit one model while the same credential remains
# usable for its sibling models. Keep that observation separate from the
# credential-wide status used for auth and billing failures.
model_cooldowns: Optional[Dict[str, float]] = None
extra: Dict[str, Any] = None # type: ignore[assignment]
def __post_init__(self):
if self.extra is None:
self.extra = {}
self.auth_type = _normalize_pool_auth_type(self.provider, self.access_token, self.auth_type)
def __getattr__(self, name: str):
if name in _EXTRA_KEYS:
return self.extra.get(name)
raise AttributeError(f"'{type(self).__name__}' object has no attribute {name!r}")
@classmethod
def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential":
field_names = {f.name for f in fields(cls) if f.name != "provider"}
data = {k: payload.get(k) for k in field_names if k in payload}
# Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch
if isinstance(data.get("last_status_at"), str):
data["last_status_at"] = _parse_absolute_timestamp(data["last_status_at"])
# Every non-field key rides in ``extra`` (to_dict writes them all back), so metadata a plugin
# stores on its own rows survives load -> save -> load. ``_EXTRA_KEYS`` stays the attribute
# surface for core logic; unknown keys are opaque payload. ``provider`` is the row's owner
# (excluded from ``field_names`` above), never metadata — sweeping it in would write a
# stray provider name back over the row on to_dict().
data["extra"] = {
k: v for k, v in payload.items() if k not in field_names and k != "provider" and v is not None
}
data.setdefault("id", uuid.uuid4().hex[:6])
data.setdefault("label", payload.get("source", provider))
data.setdefault("auth_type", AUTH_TYPE_API_KEY)
data.setdefault("priority", 0)
data.setdefault("source", SOURCE_MANUAL)
data.setdefault("access_token", "")
return cls(provider=provider, **data)
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {}
for field_def in fields(self):
if field_def.name in {"provider", "extra"}:
continue
value = getattr(self, field_def.name)
if value is not None or field_def.name in _CLEAR_STATUS:
result[field_def.name] = value
for k, v in self.extra.items():
if v is not None:
result[k] = v
return sanitize_borrowed_credential_payload(result, self.provider)
@property
def runtime_api_key(self) -> str:
if self.provider == "nous":
# Nous stores the runtime inference credential in agent_key for
# compatibility. It must be a NAS invoke JWT.
for token, expires_at in (
(self.agent_key, self.agent_key_expires_at),
(self.access_token, self.expires_at),
):
if (
isinstance(token, str)
and token.strip()
and auth_mod._nous_invoke_jwt_is_usable(
token, scope=getattr(self, "scope", None), expires_at=expires_at,
)
):
return token.strip()
return ""
return str(self.access_token or "")
@property
def runtime_base_url(self) -> Optional[str]:
if self.provider == "nous":
return self.inference_base_url or self.base_url
if self.provider == "openai-codex":
# Pool rows keep the canonical ChatGPT URL; the profile-scoped proxy override must win
# for every reader of the row — initial resolution AND a 401/429 rotation
# (client_lifecycle._swap_credential), or a rotation silently leaves the proxy.
return get_secret_str("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or self.base_url
return self.base_url
def label_from_token(token: str, fallback: str) -> str:
claims = _decode_jwt_claims(token)
for key in ("email", "preferred_username", "upn"):
value = claims.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return fallback
def _codex_principal_identity(access_token: Any) -> Optional[Tuple[str, str]]:
"""``(chatgpt_account_id, sub)`` of a Codex access token, or None when either claim is missing.
Decoded without signature verification: this only decides whether two credentials Hermes
already holds belong to the same principal, never whether a token is valid. Both claims are
required because members of one ChatGPT workspace share ``chatgpt_account_id`` yet have their
own subjects and quotas.
"""
claims = _decode_jwt_claims(access_token)
auth_claims = claims.get("https://api.openai.com/auth") if isinstance(claims, dict) else None
account_id = auth_claims.get("chatgpt_account_id") if isinstance(auth_claims, dict) else None
subject = claims.get("sub") if isinstance(claims, dict) else None
if not (isinstance(account_id, str) and account_id.strip() and isinstance(subject, str) and subject.strip()):
return None
return account_id.strip(), subject.strip()
def _codex_entry_tracks_singleton(entry: PooledCredential, singleton_tokens: Dict[str, Any]) -> bool:
"""Whether a Codex pool entry may adopt the auth.json singleton's token pair.
``device_code`` IS the singleton. ``manual:device_code`` is ambiguous: a legacy alias of the
singleton (same account, must follow its rotations) or an independent account added with
``hermes auth add openai-codex`` (must never be overwritten — adopting turned two logins into
one account, both hitting the same usage limit). Same principal proves the alias; unknown
identity fails closed.
"""
if entry.source == "device_code":
return True
if entry.source != SOURCE_MANUAL_DEVICE_CODE:
return False
entry_identity = _codex_principal_identity(entry.access_token)
return entry_identity is not None and entry_identity == _codex_principal_identity(singleton_tokens.get("access_token"))
def _next_priority(entries: List[PooledCredential]) -> int:
return max((entry.priority for entry in entries), default=-1) + 1
def _is_manual_source(source: str) -> bool:
normalized = (source or "").strip().lower()
return normalized == SOURCE_MANUAL or normalized.startswith(f"{SOURCE_MANUAL}:")
def _exhausted_ttl(
error_code: Optional[int],
*,
sole_credential: bool = False,
failure_reason: Optional[str] = None,
) -> int:
"""Return cooldown seconds based on the HTTP status that caused exhaustion.
*sole_credential*: the pool has nothing to rotate to, so transient
throttles (429 and the catch-all default covering 403/5xx/unknown) are
capped to a brief cooldown; 401 keeps its own already-short TTL.
*failure_reason* is the classifier verdict: an OpenRouter ``key limit
exceeded`` and an xAI spending block both arrive as 403 but are billing,
and a 60s retry on a spent account just re-fails. Billing keeps the full
bench regardless of status; 402 is billing by definition.
Unverified billing (#82154) gets the short cooldown regardless of pool
size (the credential may be healthy), unless the status is a true 402.
"""
if error_code == 401:
return EXHAUSTED_TTL_401_SECONDS
base = EXHAUSTED_TTL_429_SECONDS if error_code == 429 else EXHAUSTED_TTL_DEFAULT_SECONDS
if failure_reason == FAILURE_REASON_BILLING_UNVERIFIED and error_code != 402:
return min(base, EXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS)
is_billing = error_code == 402 or failure_reason == FAILURE_REASON_BILLING
if sole_credential and not is_billing:
return min(base, EXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS)
return base
def _parse_absolute_timestamp(value: Any) -> Optional[float]:
"""Best-effort parse of epoch seconds / epoch ms / ISO-8601 into epoch seconds."""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
except ValueError:
pass
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
def _singleton_predates_entry(state: Any, entry: "PooledCredential") -> bool:
"""True only when the auth.json singleton is PROVABLY older than *entry*.
Both sides stamp ``last_refresh`` on every successful rotation. When
either side lacks a parseable stamp this returns False (cannot prove),
which keeps the historical adopt-on-difference behavior (#70111) intact
for legacy writers.
"""
entry_ts = _parse_absolute_timestamp(entry.last_refresh)
if entry_ts is None:
return False
state_ts = _parse_absolute_timestamp(state.get("last_refresh") if isinstance(state, dict) else None)
if state_ts is None:
return False
return state_ts < entry_ts
def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(error_context, dict):
return {}
normalized: Dict[str, Any] = {}
for key in ("reason", "message"):
value = error_context.get(key)
if isinstance(value, str) and value.strip():
normalized[key] = value.strip()
reset_at = (
error_context.get("reset_at")
or error_context.get("resets_at")
or error_context.get("retry_until")
)
parsed_reset_at = _parse_absolute_timestamp(reset_at)
message = error_context.get("message")
if parsed_reset_at is None and isinstance(message, str):
retry_delay_seconds = reset_delay_from_message(message)
if retry_delay_seconds is not None:
parsed_reset_at = time.time() + retry_delay_seconds
if parsed_reset_at is not None:
normalized["reset_at"] = parsed_reset_at
return normalized
def _exhausted_until(entry: PooledCredential, *, sole_credential: bool = False) -> Optional[float]:
if entry.last_status != STATUS_EXHAUSTED:
return None
reset_at = _parse_absolute_timestamp(entry.last_error_reset_at)
if reset_at is not None:
return reset_at
if entry.last_status_at:
return entry.last_status_at + _exhausted_ttl(
entry.last_error_code,
sole_credential=sole_credential,
failure_reason=entry.failure_reason,
)
return None
# --- Custom (OpenAI-compatible) endpoint pool keys ---
def _normalize_custom_pool_name(name: str) -> str:
"""Normalize a custom provider name for use as a pool key suffix."""
return name.strip().lower().replace(" ", "-")
def _norm_url(url: Any) -> str:
return str(url or "").strip().rstrip("/")
def _iter_custom_providers(config: Optional[dict] = None):
"""Yield ``(normalized_name, entry)`` from the merged custom-provider config view."""
if config is None:
config = _load_config_safe()
if config is None:
return
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(config)
except Exception:
return
for entry in custom_providers or ():
if isinstance(entry, dict) and isinstance(entry.get("name"), str):
yield _normalize_custom_pool_name(entry["name"]), entry
def _custom_entry_name_aliases(norm_name: str, entry: Dict[str, Any]) -> set:
aliases = {norm_name}
provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or ""))
if provider_key:
aliases.add(provider_key)
return aliases
def _requested_custom_name_aliases(provider_name: str) -> set:
normalized = _normalize_custom_pool_name(provider_name)
aliases = {normalized} if normalized else set()
if normalized.startswith(CUSTOM_POOL_PREFIX):
suffix = _normalize_custom_pool_name(normalized[len(CUSTOM_POOL_PREFIX):])
if suffix:
aliases.add(suffix)
return aliases
def _pool_keys_for_custom_entry(norm_name: str, entry: Dict[str, Any]) -> List[str]:
"""Durable ``providers.<key>`` slug first, then legacy ``custom:<name>``."""
keys: List[str] = []
provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or ""))
for key in (provider_key, f"{CUSTOM_POOL_PREFIX}{norm_name}" if norm_name else ""):
normalized = key.strip().lower()
if normalized and normalized not in keys:
keys.append(normalized)
return keys
def custom_provider_pool_key_candidates(
base_url: Optional[str],
provider_name: Optional[str] = None,
) -> List[str]:
"""Return pool keys to try for a custom endpoint.
``hermes auth add <key>`` stores ``providers.<key>`` credentials under the
durable config slug; older rows and legacy ``custom_providers:`` entries
live under ``custom:<display-name>``. Try the slug first, then the legacy
namespace, so a populated pool is not skipped in favour of the
``no-key-required`` placeholder.
"""
if not base_url:
return []
normalized_url = _norm_url(base_url)
requested_aliases = _requested_custom_name_aliases(provider_name) if provider_name else set()
if requested_aliases:
for norm_name, entry in _iter_custom_providers():
if requested_aliases & _custom_entry_name_aliases(norm_name, entry):
return _pool_keys_for_custom_entry(norm_name, entry)
for norm_name, entry in _iter_custom_providers():
entry_url = _norm_url(entry.get("base_url"))
if entry_url and entry_url == normalized_url:
return _pool_keys_for_custom_entry(norm_name, entry)
return []
def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]:
"""Preferred pool key for a custom provider: durable slug, else ``custom:<name>``.
When provider_name is given, match by name first so two custom providers
sharing a base_url keep separate keys.
"""
candidates = custom_provider_pool_key_candidates(base_url, provider_name)
return candidates[0] if candidates else None
def list_custom_pool_providers() -> List[str]:
"""Return all 'custom:*' pool keys that have entries in auth.json."""
pool_data = read_credential_pool(None)
return sorted(
key for key in pool_data
if key.startswith(CUSTOM_POOL_PREFIX)
and isinstance(pool_data.get(key), list)
and pool_data[key]
)
def _get_custom_provider_config(pool_key: str) -> Optional[Dict[str, Any]]:
"""Return the custom_providers config entry matching a pool key like 'custom:together.ai'."""
if not pool_key.startswith(CUSTOM_POOL_PREFIX):
return None
suffix = pool_key[len(CUSTOM_POOL_PREFIX):]
return next((entry for norm_name, entry in _iter_custom_providers() if norm_name == suffix), None)
def get_pool_strategy(provider: str) -> str:
"""Return the configured selection strategy for a provider."""
config = _load_config_safe()
strategies = config.get("credential_pool_strategies") if config else None
if not isinstance(strategies, dict):
return STRATEGY_FILL_FIRST
strategy = str(strategies.get(provider, "") or "").strip().lower()
return strategy if strategy in SUPPORTED_POOL_STRATEGIES else STRATEGY_FILL_FIRST
def _keyed_custom_pool_matches(
pool_provider: str,
provider_norm: str,
base_url: Optional[str],
) -> bool:
"""Match a durable ``providers.<key>`` pool against runtime identities."""
runtime_url = _norm_url(base_url)
if not runtime_url:
return False
try:
for normalized_name, entry in _iter_custom_providers():
provider_key = _normalize_custom_pool_name(str(entry.get("provider_key") or ""))
if provider_key != pool_provider:
continue
aliases = _custom_entry_name_aliases(normalized_name, entry)
aliases.add(f"{CUSTOM_POOL_PREFIX}{normalized_name}")
if provider_key:
aliases.add(f"{CUSTOM_POOL_PREFIX}{provider_key}")
configured_url = _norm_url(entry.get("base_url"))
if provider_norm == "custom":
return runtime_url == configured_url
runtime_aliases = _requested_custom_name_aliases(provider_norm)
return bool(runtime_aliases & aliases) and runtime_url == configured_url
except Exception:
return False
return False
def _legacy_custom_pool_matches(
pool_provider: str,
provider_norm: str,
runtime_url: str,
) -> bool:
"""Match a legacy ``custom:<name>`` pool against a named runtime identity."""
try:
for normalized_name, entry in _iter_custom_providers():
if f"{CUSTOM_POOL_PREFIX}{normalized_name}" != pool_provider:
continue
aliases = {normalized_name}
for value in (entry.get("name"), entry.get("provider_key")):
alias = _normalize_custom_pool_name(str(value or ""))
if alias:
aliases.add(alias)
if alias.startswith(CUSTOM_POOL_PREFIX):
aliases.add(alias[len(CUSTOM_POOL_PREFIX):])
configured_url = _norm_url(entry.get("base_url"))
runtime_aliases = {_normalize_custom_pool_name(provider_norm)}
if provider_norm.startswith(CUSTOM_POOL_PREFIX):
runtime_aliases.add(_normalize_custom_pool_name(provider_norm[len(CUSTOM_POOL_PREFIX):]))
return bool(runtime_aliases & aliases) and runtime_url == configured_url
except Exception:
return False
return False
def credential_pool_entry_serves_endpoint(entry: Any, base_url: Any) -> bool:
"""Whether a pooled credential may be bound to a session running at ``base_url``. ``_swap_credential``
adopts the entry's base_url too, so a same-provider entry for another endpoint (public OpenAI vs. an
Azure resource) would send the session's requests — and the entry's key — to the wrong host (#68237).
Entries or sessions without endpoint metadata (legacy adapters, test doubles) cannot rebind and are accepted."""
if not isinstance(base_url, str) or not base_url:
return True
entry_url = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None)
if not isinstance(entry_url, str) or not entry_url:
return True
from hermes_cli.route_identity import normalize_route_base_url
return normalize_route_base_url(entry_url) == normalize_route_base_url(base_url)
def credential_pool_matches_provider(
pool_or_provider: Any,
provider: Optional[str],
*,
base_url: Optional[str] = None,
) -> bool:
"""Return whether a pool belongs to the requested runtime provider.
Named custom endpoints may use three identities: the live agent can retain
the configured name/provider key, newer runtime paths normalize it to
``custom``, and the pool may be keyed as the durable ``providers.<key>``
slug or as legacy ``custom:<name>``. Accept those aliases only when the
runtime endpoint belongs to the same configured custom provider. Empty
identities fail closed. Legacy pool adapters without a ``provider``
attribute remain compatible; production pools are scoped.
"""
raw_pool_provider = getattr(pool_or_provider, "provider", None)
if raw_pool_provider is None:
if not isinstance(pool_or_provider, str):
# Lightweight/unscoped pool adapters (old plugins, tests) may
# expose only select()/has_credentials().
return True
raw_pool_provider = pool_or_provider
pool_provider = str(raw_pool_provider or "").strip().lower()
provider_norm = str(provider or "").strip().lower()
if not pool_provider or not provider_norm:
return False
if not pool_provider.startswith(CUSTOM_POOL_PREFIX):
if pool_provider == provider_norm:
return True
return _keyed_custom_pool_matches(pool_provider, provider_norm, base_url)
if provider_norm == "custom":
try:
matched_pool = get_custom_provider_pool_key(base_url or "")
if str(matched_pool or "").strip().lower() == pool_provider:
return True
candidates = custom_provider_pool_key_candidates(base_url or "")
except Exception:
return False
return pool_provider in {str(key).strip().lower() for key in candidates}
runtime_url = _norm_url(base_url)
if not runtime_url:
return False
return _legacy_custom_pool_matches(pool_provider, provider_norm, runtime_url)
def resolve_runtime_pool_key(provider: Optional[str], base_url: Optional[str]) -> str:
"""Resolve the credential-pool key for a runtime provider identity.
Named custom runtimes retain their configured alias while their pool may
be stored under the durable ``providers.<key>`` slug or legacy
``custom:<name>``. Return that scoped key only when the canonical
provider/endpoint boundary accepts it; otherwise preserve the normalized
runtime identity so callers fail closed.
"""
provider_norm = str(provider or "").strip().lower()
if not provider_norm:
return ""
def _accepts(candidate: str) -> bool:
return credential_pool_matches_provider(candidate, provider_norm, base_url=base_url)
try:
if provider_norm == "custom":
candidate = get_custom_provider_pool_key(base_url)
if candidate and _accepts(candidate):
return str(candidate).strip().lower()
else:
# Named/exact custom runtimes are keyed by identity: search the
# configured candidates by identity before endpoint so a sibling
# sharing the URL cannot lend its pool.
for normalized_name, entry in _iter_custom_providers():
for candidate in _pool_keys_for_custom_entry(normalized_name, entry):
if _accepts(candidate):
return candidate
except Exception:
pass
return provider_norm
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
# --- Multi-profile root write-through ---
def _guarded_global_root(global_path: Optional[Path]) -> Optional[Path]:
"""Apply the pytest seat belt to a resolved global-root auth.json path.
``None`` means classic mode (profile == root) or "refuse": under pytest,
never write the real user's ``~/.hermes/auth.json`` even when HERMES_HOME
points at a profile path (mirrors the read-side guard in
``_load_global_auth_store``). Uses the unmodified HOME env, not
``Path.home()`` which fixtures may monkeypatch.
"""
if global_path is None:
return None
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_env = os.environ.get("HOME", "")
if real_home_env:
real_root = Path(real_home_env) / ".hermes" / "auth.json"
try:
# Comparing the guard path must not probe the real auth store.
if os.path.normcase(os.path.abspath(global_path)) == os.path.normcase(os.path.abspath(real_root)):
return None
except Exception:
return None
return global_path
def _write_through_provider_state_to_global_root(
provider_id: str, state: Dict[str, Any]
) -> None:
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
Best-effort write-through for the multi-profile rotation hazard: nous,
openai-codex, and xai-oauth rotate the refresh_token on refresh, so when
a profile pool refresh rotates a grant it resolved from the root fallback,
the rotated chain must land back in root. Otherwise root keeps a revoked
refresh token and every other profile dies with ``refresh_token_reused``
/ ``invalid_grant`` once its access token expires.
Only updates ``providers.<provider_id>`` in the root store; never touches
the profile store (the caller already saved that). Swallows all errors —
a failed write-through degrades to root-stale and must never break the
profile's own successful save. Mirrors
``hermes_cli.auth._write_through_xai_oauth_to_global_root``.
See #48415.
"""
try:
global_path = _guarded_global_root(auth_mod._global_auth_file_path())
except Exception:
return
if global_path is None:
return
try:
auth_mod._persist_provider_state_to_store(provider_id, state, global_path, set_active=False)
except Exception as exc: # pragma: no cover - best effort
logger.debug("%s pool refresh: write-through to global root failed: %s", provider_id, exc)
def _singleton_target_for_entry(pool: "CredentialPool", entry: "PooledCredential") -> Optional[Path]:
"""Root ``.anthropic_oauth.json`` when *entry* is a borrowed hermes_pkce row, else None."""
if entry.source != "hermes_pkce" or entry.id not in getattr(pool, "_borrowed_root_ids", ()):
return None
try:
from agent.anthropic_credentials import _root_hermes_oauth_file
return _root_hermes_oauth_file()
except Exception:
return None
def _store_owns_pool_provider(auth_store: Dict[str, Any], provider: str) -> bool:
"""True when an already-loaded *auth_store* has its own rows for *provider*."""
pool = auth_store.get("credential_pool")
entries = pool.get(provider) if isinstance(pool, dict) else None
return isinstance(entries, list) and bool(entries)
def _profile_owns_pool_provider(provider: str) -> bool:
"""True when the ACTIVE auth.json has its own rows for *provider*.
Named profiles with no local rows read the provider through the
``read_credential_pool`` global-root fallback ("borrowing").
"""
# Classic mode (profile == root) has no root fallback, so the answer is always "owns";
# skip the per-call auth.json re-read on this hot load_pool path.
if auth_mod._global_auth_file_path() is None:
return True
try:
auth_store = _load_auth_store()
except Exception:
return True # unreadable store: assume ownership, keep legacy path
return _store_owns_pool_provider(auth_store, provider)
def _borrowed_single_use_pool_root() -> Optional[Path]:
"""Global-root auth.json when persisting a BORROWED single-use pool, else None.
``None`` means "persist to the active store as usual": classic mode
(profile == root), or the profile owns its own rows for this provider.
"""
try:
return _guarded_global_root(_global_auth_file_path())
except Exception:
return None
def _update_root_pool_rows(
provider: str, payloads: List[Dict[str, Any]], global_path: Path,
*, status_cleared_ids: Optional[Iterable[str]] = None,
token_bases: Optional[Dict[str, Tuple[Any, Any]]] = None,
) -> List[Dict[str, Any]]:
"""UPDATE-ONLY merge of *payloads* into the root store's rows for *provider*.
A borrower may refresh the root's rows (rotation, cooldown state) but
never add or delete them — the root owns their lifecycle. In particular a
profile's singleton-prune (it has no ``.anthropic_oauth.json`` of its own)
must not delete the root grant, so ``removed_ids`` is ignored by callers.
"""
with _auth_store_lock(target_path=global_path):
store = _load_auth_store(global_path)
pool = store.get("credential_pool")
if not isinstance(pool, dict):
pool = {}
store["credential_pool"] = pool
existing = pool.get(provider)
existing_list = existing if isinstance(existing, list) else []
incoming_by_id = auth_mod._entry_ids(payloads)
cleared = {cid for cid in (status_cleared_ids or ()) if cid}
bases = token_bases or {}
merged: List[Dict[str, Any]] = []
changed = False
for disk_entry in existing_list:
did = disk_entry.get("id") if isinstance(disk_entry, dict) else None
incoming = incoming_by_id.get(did) if did else None
if incoming is None:
merged.append(disk_entry)
continue
updated = auth_mod._merge_pool_row_generation(
incoming, disk_entry, provider,
base_pair=bases.get(did), status_cleared=did in cleared,
)
if updated != disk_entry:
changed = True
merged.append(updated)
if changed:
pool[provider] = merged
_save_auth_store(store, target_path=global_path)
return merged
def persist_pool_entries(
provider: str,
payloads: List[Dict[str, Any]],
*,
removed_ids: Optional[Iterable[str]] = None,
status_cleared_ids: Optional[Iterable[str]] = None,
token_bases: Optional[Dict[str, Tuple[Any, Any]]] = None,
) -> Optional[List[Dict[str, Any]]]:
"""Persist a provider's pool rows to the store that OWNS them.
A named profile that sees a single-use-refresh provider (see
``SINGLE_USE_REFRESH_POOL_PROVIDERS``) only through the global-root fallback must not
materialize a local ``credential_pool.<provider>`` copy: that copy forks
the single-use refresh token, the first profile to rotate commits the new
pair only to its own file, and root plus every sibling die with
``invalid_grant`` (#100339). Such rows are written back to the root store
(under the root lock); everything else goes to the active store.
"""
if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS and not _profile_owns_pool_provider(provider):
global_path = _borrowed_single_use_pool_root()
if global_path is not None:
try:
return _update_root_pool_rows(
provider, payloads, global_path,
status_cleared_ids=status_cleared_ids, token_bases=token_bases,
)
except Exception as exc:
# Fail closed on the FORK, not on the save: never fall back to
# writing a local copy (that IS the bug). The in-memory pool
# still holds the rotated pair for this process.
logger.warning(
"%s pool: write-through of borrowed root grant failed (%s); "
"not materializing a profile-local copy",
provider, exc,
)
return None
return write_credential_pool(
provider, payloads, removed_ids=removed_ids, status_cleared_ids=status_cleared_ids,
token_bases=token_bases,
)
# --- Per-provider singleton refresh plumbing -------------------------------
#
# Providers whose OAuth singleton lives in auth.json ``providers.<id>.tokens``
# (Codex, xAI): log names (sync-message form, "<name> OAuth" form),
# ``hermes_cli.auth`` refresh function and terminal-error predicate (looked
# up at call time so tests can patch them).
_TOKENS_SINGLETON_PROVIDERS: Dict[str, Tuple[str, str, str, str]] = {
"openai-codex": ("Codex", "Codex", "refresh_codex_oauth_pure", "_is_terminal_codex_oauth_refresh_error"),
"xai-oauth": ("xAI OAuth", "xAI", "refresh_xai_oauth_pure", "_is_terminal_xai_oauth_refresh_error"),
}
# Built-in providers whose pooled OAuth entries ``_refresh_entry_impl`` can actually refresh. Plugin
# providers are refreshable when their profile ships ``refresh_credential`` (see
# ``hermes_cli.auth_plugin_providers.is_refreshable_oauth_provider``); any other provider is returned
# unchanged by that path, so callers must not report a refresh for them.
REFRESHABLE_OAUTH_PROVIDERS = frozenset({"anthropic", "nous", *_TOKENS_SINGLETON_PROVIDERS})
# Providers whose refresh tokens are single-use: the sync -> POST -> write-back
# sequence must be serialized across processes under the auth-store flock.
# ``nous`` is deliberately absent even though it is in SINGLE_USE_REFRESH_POOL_PROVIDERS:
# its refresh path serializes on its own auth-store lock (``_refresh_entry_impl`` nous branch).
_SINGLE_USE_REFRESH_PROVIDERS = ("openai-codex", "xai-oauth", "anthropic")
_REFRESH_TIMEOUT_ENV_VARS = {
"openai-codex": "HERMES_CODEX_REFRESH_TIMEOUT_SECONDS",
"xai-oauth": "HERMES_XAI_REFRESH_TIMEOUT_SECONDS",
}
# Singleton-seeded source whose exhausted/DEAD pool row may be revived by a
# re-auth another process wrote to the provider's store.
_RESYNC_SOURCE = {
"anthropic": "claude_code",
"nous": "device_code",
"openai-codex": "device_code",
"xai-oauth": "device_code",
}
class _RefreshDone(Exception):
"""Raised inside a provider refresher to short-circuit ``_refresh_entry_impl`` with ``result``."""
def __init__(self, result: Optional["PooledCredential"]):
super().__init__()
self.result = result
class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin):
def __init__(self, provider: str, entries: List[PooledCredential]):
self.provider = provider
self._entries = sorted(entries, key=lambda entry: entry.priority)
self._current_id: Optional[str] = None
# Ids of rows read via the global-root fallback (single-use OAuth
# providers only); set by load_pool(), consumed by add_entry().
self._borrowed_root_ids: Set[str] = set()
self._persisted_token_pairs: Dict[str, Tuple[Any, Any]] = {}
self._strategy = get_pool_strategy(provider)
# RLock: _replace_entry/_persist self-acquire it so the DEFERRED
# single-use-token refresh path (network I/O outside the lock by
# design) still serializes its pool mutations; in-lock callers
# re-acquire reentrantly.
self._lock = threading.RLock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
# Monotonic timestamp of the last "no available entries" log (see
# NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS). Re-armed to None on every
# successful selection so a recover->re-exhaust transition logs promptly.
self._last_no_entries_log_at: Optional[float] = None
# #70401: consecutive mark_exhausted_and_rotate() calls whose supplied
# credential identity matched no pool entry. These mark nothing
# exhausted, so without a cap the pool never converges to "no available
# entries" and the caller's 401 retry loop runs unbounded. Reset when a
# real entry is identified or an escape path returns None.
self._unmatched_rotation_streak: int = 0
# ---- read accessors ---------------------------------------------------
def has_credentials(self) -> bool:
with self._lock:
return bool(self._entries)
def has_available(self, *, model: Optional[str] = None) -> bool:
"""True if at least one entry is not currently in exhaustion cooldown.
``_available_entries`` is not read-only (it prunes aged-out DEAD
manual entries and persists), so it must run under ``self._lock``
like every other caller or a probe can race a concurrent rotation.
"""
with self._lock:
available, _pending = self._available_entries(model=model)
return bool(available)
def next_available_at(self, *, model: Optional[str] = None) -> Optional[float]:
"""Earliest epoch time (seconds) any entry re-enters rotation.
``None`` when an entry is available now, or when no exhausted entry
carries a usable recovery time (empty pool, or only ``STATUS_DEAD``
entries). Callers must treat ``None`` as "no wait information".
Runs under ``self._lock`` for the same reason as ``has_available``.
"""
with self._lock:
available, _pending = self._available_entries(model=model)
if available:
return None
# Mirror _available_entries: a sole credential's transient throttle
# cools down in seconds, and the fallback restore gate must not
# wait an hour for a 60s cooldown.
sole_credential = self._is_sole_credential()
candidates = [
until
for until in (
_exhausted_until(entry, sole_credential=sole_credential)
for entry in self._entries
if entry.last_status == STATUS_EXHAUSTED
)
if until is not None
]
candidates.extend(
until
for entry in self._entries
if entry.last_status != STATUS_DEAD
for until in (model_cooldown_until(entry, model),)
if until is not None
)
return min(candidates) if candidates else None
def entries(self) -> List[PooledCredential]:
with self._lock:
return list(self._entries)
def _is_sole_credential(self) -> bool:
"""DEAD entries never re-enter rotation, so <=1 non-DEAD entry means nothing to rotate to."""
return sum(1 for e in self._entries if e.last_status != STATUS_DEAD) <= 1
def _find(self, predicate: Callable[[PooledCredential], bool]) -> Optional[PooledCredential]:
return next((e for e in self._entries if predicate(e)), None)
def _current_unlocked(self) -> Optional[PooledCredential]:
if not self._current_id:
return None
return self._find(lambda e: e.id == self._current_id)
def current(self) -> Optional[PooledCredential]:
with self._lock:
return self._current_unlocked()
def entry_id_for_api_key(self, api_key_hint: Any = None) -> Optional[str]:
"""Stable id for the runtime credential in use.
Prefer the current selection when it still supplies ``api_key_hint``;
if the cursor was cleared, fall back to an unambiguous key match.
"""
with self._lock:
current = self._current_unlocked()
if current is not None and (api_key_hint is None or current.runtime_api_key == api_key_hint):
return current.id
if api_key_hint is None:
return None
matches = [e for e in self._entries if e.runtime_api_key == api_key_hint]
return matches[0].id if len(matches) == 1 else None
# ---- mutation primitives (self-locking) --------------------------------
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
"""Swap an entry in-place by id, preserving sort order.
Self-locking (RLock) so the deferred refresh path — which runs outside
the pool lock — cannot tear ``self._entries`` against a concurrent
select()/rotation.
"""
with self._lock:
for idx, entry in enumerate(self._entries):
if entry.id == old.id:
self._entries[idx] = new
return
def _persist(
self,
*,
removed_ids: Optional[List[str]] = None,
status_cleared_ids: Optional[List[str]] = None,
) -> None:
# Self-locking: snapshotting self._entries must not race a rotation.
with self._lock:
payloads = [entry.to_dict() for entry in self._entries]
written = persist_pool_entries(
self.provider, payloads,
removed_ids=removed_ids,
status_cleared_ids=status_cleared_ids,
token_bases=self._persisted_token_pairs,
)
if written is None:
return
rows = auth_mod._entry_ids(written)
self._persisted_token_pairs = auth_mod._token_pairs_by_id(written)
for entry in self._entries:
row = rows.get(entry.id)
pair = self._persisted_token_pairs.get(entry.id, (None, None))
# Reference-only rows are intentionally secret-free on disk; never dehydrate
# their live in-memory credential while adopting a concurrent generation.
if row is None or not any(pair):
continue
# Adopt only rows the store overrode with a peer's newer pair; re-hydrating an
# unchanged row would replace the live object (and pull peer cooldown state
# merged into the written row) on every ordinary flush.
if pair != (entry.access_token, entry.refresh_token):
self._replace_entry(entry, PooledCredential.from_dict(self.provider, row))
def _adopt(self, entry: PooledCredential, *, persist: bool = True, **updates: Any) -> PooledCredential:
"""``replace(entry, **updates)``, swap it into the pool, optionally persist."""
updated = replace(entry, **updates)
self._replace_entry(entry, updated)
if persist:
self._persist()
# _persist may have swapped in a peer's newer token generation; hand callers
# the live entry so they don't rebind the client to the stale pair.
return self._find(lambda e: e.id == updated.id) or updated
return updated
def _quarantine_sources(self, entry: PooledCredential, sources: Set[str]) -> None:
"""Drop every entry seeded from *sources* and persist the removal.
Atomic read-modify-write of ``self._entries``: this runs on the
DEFERRED refresh path (outside the pool lock), so take the RLock here;
still-locked callers re-enter safely.
"""
with self._lock:
removed_ids = [item.id for item in self._entries if item.source in sources]
self._entries = [item for item in self._entries if item.source not in sources]
if self._current_id == entry.id:
self._current_id = None
self._persist(removed_ids=removed_ids)
# ---- exhaustion --------------------------------------------------------
def _is_terminal_auth_failure(
self,
status_code: Optional[int],
normalized_error: Dict[str, Any],
) -> bool:
"""Detect upstream-permanent OAuth failures that won't recover on TTL.
Only 401s whose reason is a known terminal OAuth state count;
token_expired (refreshable) and reason-less 401s (possible glitch)
stay transient, as do 429/402. The one status-independent case is
``CREDENTIAL_PERSIST_FAILED_REASON``: no upstream response is involved,
the rotated pair never became durable and only a re-auth recovers it.
"""
raw_reason = normalized_error.get("reason")
reason = raw_reason.strip().lower() if isinstance(raw_reason, str) else ""
if reason == CREDENTIAL_PERSIST_FAILED_REASON:
return True
return status_code == 401 and reason in _TERMINAL_AUTH_REASONS
def _mark_exhausted(
self,
entry: PooledCredential,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
*,
persist: bool = True,
failure_reason: Optional[str] = None,
) -> PooledCredential:
normalized_error = _normalize_error_context(error_context)
# Permanent OAuth failures become STATUS_DEAD, not STATUS_EXHAUSTED:
# otherwise a revoked credential re-enters rotation every hour and
# fails immediately until the user removes it (#32849).
terminal = self._is_terminal_auth_failure(status_code, normalized_error)
# Carry the classifier's verdict so the cooldown is sized by what
# actually failed (a billing 403 must not get the sole-credential
# transient cooldown); absent a classification, clear a stale one.
updated_extra = dict(entry.extra)
if failure_reason:
updated_extra["failure_reason"] = failure_reason
else:
updated_extra.pop("failure_reason", None)
return self._adopt(
entry,
persist=persist,
last_status=STATUS_DEAD if terminal else STATUS_EXHAUSTED,
last_status_at=time.time(),
last_error_code=status_code,
last_error_reason=normalized_error.get("reason"),
last_error_message=normalized_error.get("message"),
last_error_reset_at=normalized_error.get("reset_at"),
extra=updated_extra,
)
# ---- cross-process token resync ---------------------------------------
#
# OAuth refresh tokens are single-use. When another process (CLI, another
# profile, a concurrent cron) rotates a pair, our in-memory entry holds a
# consumed refresh token; replaying it yields ``refresh_token_reused`` /
# ``invalid_grant``. These helpers adopt the fresher pair from wherever the
# provider's token authority lives, clearing stale exhaustion state.
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
"""Sync a claude_code entry from ~/.claude/.credentials.json if tokens differ."""
if self.provider != "anthropic" or entry.source != "claude_code":
return entry
try:
from agent.anthropic_credentials import read_claude_code_credentials
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
# Access tokens can be re-issued without a new refresh token, so
# checking only refresh_token leaves a stale access_token in the
# pool -> 401 on every request until the exhausted TTL expires.
if (file_access or file_refresh) and (
(file_access and file_access != (entry.access_token or ""))
or (file_refresh and file_refresh != (entry.refresh_token or ""))
):
logger.debug("Pool entry %s: syncing tokens from credentials file (tokens changed)", entry.id)
return self._adopt(
entry,
access_token=file_access or entry.access_token,
refresh_token=file_refresh or entry.refresh_token,
expires_at_ms=creds.get("expiresAt", 0) or entry.expires_at_ms,
**_CLEAR_STATUS,
)
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def _sync_entry_from_pool_store(self, entry: PooledCredential) -> PooledCredential:
"""Adopt a token pair rotated by another pool instance (anthropic, xai-oauth).
Re-reads the exact persisted row from the credential-pool store while
the shared cross-process auth-store lock is held. Direct integrations
load a fresh ``CredentialPool`` per request, so in-memory locks cannot
protect a single-use refresh token across requests or processes.
Anthropic borrowed sources (``claude_code``) are excluded: they are
reference-only rows whose secrets are stripped before reaching
auth.json, so re-reading yields empty tokens that would be adopted as
a "rotation" — blanking a usable credential. The singleton file, not
the pool store, is token authority for those sources; a row with no
token material at all is refused for the same reason.
"""
if self.provider not in ("anthropic", "xai-oauth") and plugin_refresh_hook(self.provider) is None:
return entry
is_anthropic = self.provider == "anthropic"
is_xai = self.provider == "xai-oauth"
display = {"anthropic": "Anthropic", "xai-oauth": "xAI"}.get(self.provider, self.provider)
if is_anthropic and is_borrowed_credential_source(entry.source, self.provider):
return entry
try:
persisted = next(
(p for p in read_credential_pool(self.provider) if isinstance(p, dict) and p.get("id") == entry.id),
None,
)
if not isinstance(persisted, dict):
return entry
# Same base policy as _persist/load_pool: a token-less disk row is a known
# (blank) generation, recorded before the no-token-material bail-out below.
self._persisted_token_pairs[entry.id] = auth_mod._credential_token_pair(persisted)
stored = PooledCredential.from_dict(self.provider, persisted)
# No token material at all is never a "rotation" (anthropic borrowed rows, a plugin row a
# peer blanked mid-write): adopting it would replace a usable credential with nothing.
if not is_xai and not (stored.access_token or "").strip() and not (stored.refresh_token or "").strip():
return entry
if stored.access_token != entry.access_token or stored.refresh_token != entry.refresh_token:
logger.debug(
"Pool entry %s: adopting %s OAuth tokens rotated by another pool instance",
entry.id, display,
)
self._replace_entry(entry, stored)
return stored
except Exception as exc:
logger.debug("Failed to sync %s OAuth entry from credential pool: %s", display, exc)
return entry
_sync_anthropic_entry_from_pool_store = _sync_entry_from_pool_store
def _sync_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
"""Sync a Codex / xAI device_code entry from auth.json ``providers.<id>.tokens``.
A fresh ``hermes model`` / ``hermes auth`` login writes new tokens
under ``_auth_store_lock`` while the pool entry may sit frozen behind
a ``last_error_reset_at`` hours in the future; without this sync every
request fails with "no available entries" despite fresh credentials on
disk. Only singleton-seeded entries apply — env/API-key rows have no
auth.json shadow.
"""
spec = _TOKENS_SINGLETON_PROVIDERS.get(self.provider)
if spec is None:
return entry
display = spec[0]
is_codex = self.provider == "openai-codex"
sources = ("device_code", "manual:device_code") if is_codex else ("device_code",)
if entry.source not in sources:
return entry
try:
with _auth_store_lock():
state = _load_provider_state(_load_auth_store(), self.provider)
tokens = state.get("tokens") if isinstance(state, dict) else None
if not isinstance(tokens, dict):
return entry
if is_codex and not _codex_entry_tracks_singleton(entry, tokens):
return entry
store_access = tokens.get("access_token", "")
store_refresh = tokens.get("refresh_token", "")
entry_refresh = entry.refresh_token or ""
# Adopt when either side differs: a fresh refresh_token from
# another process means our pair is consumed/stale.
should_adopt = bool(store_access) and (
store_access != (entry.access_token or "")
or (store_refresh and store_refresh != entry_refresh)
)
if not should_adopt and is_codex and store_refresh and store_refresh != entry_refresh and not store_access:
# Store has only a refresh_token — another process rotated the
# pair and the access_token was consumed. 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 and _singleton_predates_entry(state, entry):
# #106705: manual:* entries never write back to the singleton
# (#39236), so after a pool-side rotation the singleton sits
# one chain behind. Adopting it would replay the consumed
# refresh token. ``last_refresh`` is stamped on every
# successful rotation on both sides; when either side lacks a
# parseable stamp this falls through to the historical
# adopt-on-difference above (#70111).
logger.info(
"Pool entry %s: auth.json singleton predates this entry's "
"rotation (last_refresh %s < %s); keeping pool chain to "
"avoid replaying the consumed refresh token",
entry.id,
state.get("last_refresh") if isinstance(state, dict) else None,
entry.last_refresh,
)
should_adopt = False
if should_adopt:
logger.debug(
"Pool entry %s: syncing %s tokens from auth.json (refreshed by another process)",
entry.id, display,
)
field_updates: Dict[str, Any] = {
"access_token": store_access or entry.access_token,
"refresh_token": store_refresh or entry.refresh_token,
**_CLEAR_STATUS,
}
if state.get("last_refresh"):
field_updates["last_refresh"] = state["last_refresh"]
return self._adopt(entry, **field_updates)
except Exception as exc:
logger.debug("Failed to sync %s entry from auth.json: %s", display, exc)
return entry
def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
"""Sync a Nous device_code entry from auth.json ``providers.nous`` if state differs.
Another process refreshing via ``resolve_nous_runtime_credentials``
writes fresh tokens under ``_auth_store_lock``; adopting them avoids a
"refresh token reuse" revocation on the Nous Portal.
"""
if self.provider != "nous" or entry.source != "device_code":
return entry
try:
with _auth_store_lock():
state = _load_provider_state(_load_auth_store(), "nous")
if not state:
return entry
comparable = {
key: state.get(key)
for key in (
"access_token", "refresh_token", "expires_at",
"agent_key", "agent_key_expires_at", "inference_base_url",
)
}
if not any(v not in (None, "") and getattr(entry, k, None) != v for k, v in comparable.items()):
return entry
logger.debug("Pool entry %s: syncing Nous state from auth.json", entry.id)
field_updates: Dict[str, Any] = dict(_CLEAR_STATUS)
field_updates.update({k: v for k, v in comparable.items() if v})
extra_updates = dict(entry.extra)
extra_updates.update(
{k: state[k] for k in _NOUS_EXTRA_STATE_KEYS if state.get(k) is not None}
)
return self._adopt(entry, extra=extra_updates, **field_updates)
except Exception as exc:
logger.debug("Failed to sync Nous entry from 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.<id>``.
Otherwise the next ``load_pool()`` re-seeds the stale singleton state
over the fresh entry — potentially a consumed single-use refresh
token. Applies to Nous, OpenAI Codex and xAI OAuth singletons.
``set_active=False`` everywhere: a sync-back is a token-rotation side
effect, not the user choosing a provider; ``_save_provider_state``
would flip ``active_provider`` to whichever provider refreshed last.
#74339: decide the root write-through on WHERE the state resolved
from (``_load_provider_state_with_source``), not on whether the
profile has a ``providers.<id>`` key — ``_store_provider_state``
creates that key unconditionally, which self-sealed the check after
the first refresh. When the grant came from the global root, write
back to root ONLY and skip the profile store so it never accrues a
shadowing key that blocks both the fallback and the write-through.
"""
# Only singleton-seeded entries sync back; ``manual:*`` entries are
# independent credentials and must not write to the singleton.
if entry.source != "device_code" or self.provider not in ("nous", *_TOKENS_SINGLETON_PROVIDERS):
return
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state, source_path = _load_provider_state_with_source(auth_store, self.provider)
if not isinstance(state, dict):
return
global_root = _global_auth_file_path()
is_from_root = bool(
source_path is not None and global_root is not None and _same_path(source_path, global_root)
)
if not self._apply_entry_to_singleton_state(entry, state):
return
if is_from_root:
_write_through_provider_state_to_global_root(self.provider, state)
else:
_store_provider_state(auth_store, self.provider, state, set_active=False)
_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 _apply_entry_to_singleton_state(self, entry: PooledCredential, state: Dict[str, Any]) -> bool:
"""Copy *entry*'s tokens into the provider's auth.json ``state`` in place."""
if self.provider == "nous":
state["access_token"] = entry.access_token
for key in ("refresh_token", "expires_at", "agent_key", "agent_key_expires_at"):
if getattr(entry, key):
state[key] = getattr(entry, key)
for extra_key in _NOUS_EXTRA_STATE_KEYS:
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
return True
tokens = state.get("tokens")
if not isinstance(tokens, dict):
return False
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
return True
# ---- refresh -----------------------------------------------------------
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
if force:
self._mark_exhausted(entry, None)
return None
# Plugin providers with a ``refresh_credential`` hook are treated as single-use by default:
# the pool cannot know their grant semantics, and a needless in-lock re-read is cheaper than
# a ``refresh_token_reused`` login loss. Eligibility comes from the hook, never a name set.
if self.provider not in _SINGLE_USE_REFRESH_PROVIDERS and plugin_refresh_hook(self.provider) is None:
return self._refresh_entry_impl(entry, force=force)
# Single-use refresh tokens: sync -> POST -> write-back must be atomic
# across Hermes processes, or two processes adopt the same on-disk
# token, both POST it, and the loser gets ``refresh_token_reused`` /
# ``invalid_grant`` (for Anthropic sources other than claude_code
# there was no recovery path at all). Serialize through the shared
# cross-process auth-store flock; a waiter's in-lock re-sync picks up
# the winner's rotated token and skips the POST.
with _auth_store_lock(timeout_seconds=self._single_use_refresh_lock_timeout()):
if self.provider == "openai-codex":
synced = self._sync_entry_from_auth_store(entry)
if synced is not entry and not force and not self._entry_needs_refresh(synced):
return synced
return self._refresh_entry_impl(synced, force=force)
synced = self._sync_entry_from_pool_store(entry)
if self.provider == "anthropic" and synced.source == "claude_code":
# claude_code entries are NOT profile-owned: the refresh token
# lives in one shared ~/.claude/.credentials.json (or Keychain)
# every profile reads. The profile-scoped lock above only covers
# THIS profile's auth.json, so take the dedicated shared-file
# lock (inner, per the ordering invariant on ``_auth_store_lock``)
# and re-read that authoritative file before any
# adopt-and-return shortcut fires. The official ``claude`` CLI
# rotating out-of-band is handled by the sync-and-retry-once
# fallback in ``_recover_failed_refresh``.
with self._claude_code_credentials_lock():
synced = self._sync_anthropic_entry_from_credentials_file(synced)
if synced.refresh_token != entry.refresh_token:
return synced
return self._refresh_entry_impl(synced, force=force)
if synced.access_token != entry.access_token or synced.refresh_token != entry.refresh_token:
return synced
return self._refresh_entry_impl(synced, force=force)
def _claude_code_credentials_lock(self):
"""Cross-process lock keyed to the shared claude_code credentials file.
Unlike the per-profile ``_auth_store_lock()`` this serializes every
profile and process that might refresh a ``claude_code`` entry.
"""
from agent.anthropic_credentials import claude_code_credentials_path
return _auth_store_lock(
timeout_seconds=self._single_use_refresh_lock_timeout(),
target_path=claude_code_credentials_path(),
)
def _fail_closed_unpersisted_rotation(
self,
entry: PooledCredential,
exc: BaseException,
*,
store: str,
) -> None:
"""Quarantine an entry whose rotated pair never reached its store.
For ``claude_code`` / ``hermes_pkce`` the singleton file — not
auth.json — is authoritative: ``_seed_from_singletons()`` re-reads it
on every ``load_pool()``. When the refresh POST succeeded but the
singleton write failed, the replacement pair exists only in memory
while the consumed pair survives on disk and would be re-seeded over
any row we persisted; the next refresh would replay the spent token.
So never expose or persist the rotated pair; mark the entry terminally
so it surfaces as an explicit re-auth requirement.
"""
logger.error(
"Anthropic %s refresh rotated the single-use token but could not commit it "
"to %s (%s) — failing closed and quarantining the credential; "
"re-authenticate to recover",
entry.source, store, exc,
)
try:
from agent.anthropic_credentials import (
mark_rotation_consumed_uncommitted,
spent_rotation_source_path,
)
# The singleton still holds the spent pair and load_pool() re-seeds
# it, so record the fingerprints — persisted to the shared source's
# sidecar registry (we hold its path-keyed lock here) so OTHER
# processes/profiles adopt the terminal verdict instead of leasing
# the stale pair or re-POSTing the spent refresh token.
mark_rotation_consumed_uncommitted(
entry.access_token,
entry.refresh_token,
source_path=spent_rotation_source_path(entry.source),
)
except Exception: # pragma: no cover - never block the quarantine
logger.debug("Failed to record consumed rotation fingerprints", exc_info=True)
self._mark_exhausted(
entry,
None,
{
"reason": CREDENTIAL_PERSIST_FAILED_REASON,
"message": f"rotated credential was not durably written to {store}: {exc}",
},
)
return None
def _single_use_refresh_lock_timeout(self) -> float:
"""Configured refresh POST timeout plus margin, so a slow token endpoint cannot starve the flock."""
env_var = _REFRESH_TIMEOUT_ENV_VARS.get(self.provider, "HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS")
refresh_timeout_seconds = auth_mod.env_float(env_var, 20)
return max(float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS), float(refresh_timeout_seconds) + 5.0)
def _commit_anthropic_rotation(
self, entry: PooledCredential, refreshed: Dict[str, Any]
) -> None:
"""Write a rotated Anthropic pair to its authoritative singleton, or fail closed.
claude_code -> ~/.claude/.credentials.json (so the fallback resolver
and other profiles see it). hermes_pkce -> ~/.hermes/.anthropic_oauth.json
(``_seed_from_singletons`` re-seeds it every load; a borrowed row commits
to the ROOT's file, never a new profile-local copy, #100339). Not
``endswith``: manual:hermes_pkce is pool-owned and a singleton for it
would be a second authority for the same refresh-token family.
"""
if entry.source == "claude_code":
store = "~/.claude/.credentials.json"
elif entry.source == "hermes_pkce":
store = "~/.hermes/.anthropic_oauth.json"
else:
return
try:
from agent import anthropic_credentials as ac
args = (refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"])
if entry.source == "claude_code":
ac._write_claude_code_credentials(*args, spent_refresh_token=entry.refresh_token or "")
else:
ac._write_hermes_oauth_credentials(*args, target=_singleton_target_for_entry(self, entry))
except Exception as wexc:
# Authoritative commit failed: do not mark, persist or return the
# rotation as successful, and bypass the re-POST recovery path —
# there is nothing left to retry with.
raise _RefreshDone(self._fail_closed_unpersisted_rotation(entry, wexc, store=store))
def _refresh_anthropic(self, entry: PooledCredential) -> PooledCredential:
"""POST the Anthropic refresh, commit to the singleton, return the rotated (unpersisted) entry."""
from agent.anthropic_credentials import (
is_rotation_consumed_uncommitted,
refresh_anthropic_oauth_pure,
spent_rotation_source_path,
)
# Never POST a refresh token another process already spent: the
# durable sidecar verdict is what a fresh interpreter sees here.
source_path = spent_rotation_source_path(entry.source)
if is_rotation_consumed_uncommitted(entry.refresh_token, source_path=source_path) or (
is_rotation_consumed_uncommitted(entry.access_token, source_path=source_path)
):
raise _RefreshDone(self._fail_closed_unpersisted_rotation(
entry,
RuntimeError(
"credential pair was rotated by another process but the "
"rotation never committed (spent-rotation sidecar verdict)"
),
store=str(source_path or "credential store"),
))
refreshed = refresh_anthropic_oauth_pure(entry.refresh_token, use_json=entry.source.endswith("hermes_pkce"))
updated = replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
self._commit_anthropic_rotation(entry, refreshed)
return updated
def _post_tokens_refresh(self, entry: PooledCredential) -> PooledCredential:
"""Codex / xAI: POST the refresh and return the rotated (unpersisted) entry."""
refresh_fn_name = _TOKENS_SINGLETON_PROVIDERS[self.provider][2]
refreshed = getattr(auth_mod, refresh_fn_name)(entry.access_token, entry.refresh_token)
return replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
last_refresh=refreshed.get("last_refresh"),
)
def _refresh_entry_impl(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
# Single-use-token providers adopt fresher tokens from their store
# BEFORE spending the refresh_token; ``entry`` is rebound to the synced
# row so the failure path below recovers against the pair we POSTed.
try:
if self.provider == "anthropic":
updated = self._refresh_anthropic(entry)
elif self.provider in _TOKENS_SINGLETON_PROVIDERS:
entry = self._sync_entry_from_auth_store(entry)
updated = self._post_tokens_refresh(entry)
elif (plugin_refresh := plugin_refresh_hook(self.provider)) is not None:
rotated = plugin_refresh(entry)
if not rotated:
# ``None``/empty = the plugin could not rotate: bench like a failed refresh POST, never
# report the stale row as refreshed (the loop would replay the dead bearer).
raise RuntimeError("provider refresh_credential returned no rotated fields")
updated = apply_plugin_refresh_result(entry, rotated)
elif self.provider == "nous":
stale_key = entry.runtime_api_key or entry.agent_key or entry.access_token
synced = self._sync_nous_entry_from_auth_store(entry)
if synced is not entry:
entry = synced
# A peer already rotated and persisted a usable key: adopt
# it without consuming the single-use refresh token again.
if force and entry.runtime_api_key and entry.runtime_api_key != stale_key:
logger.debug("Nous entry %s: adopting peer-rotated token, skipping refresh", entry.id)
return entry
auth_mod.resolve_nous_runtime_credentials(force_refresh=force, stale_access_token=stale_key or None)
updated = self._sync_nous_entry_from_auth_store(entry)
else:
return entry
except _RefreshDone as done:
return done.result
except Exception as exc:
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
return self._recover_failed_refresh(entry, exc)
updated = replace(updated, **_MARK_OK)
self._replace_entry(entry, updated)
# Declare the cleared id: a borrowed row carries no access_token on disk, so
# the merge's token-change bypass cannot apply and a plain persist would copy
# the still-binding cooldown back over this success.
self._persist(status_cleared_ids=[updated.id])
# Sync back so _seed_from_singletons() on the next load_pool() sees
# fresh state instead of re-seeding consumed tokens.
self._sync_device_code_entry_to_auth_store(updated)
return updated
def _recover_failed_refresh(self, entry: PooledCredential, exc: Exception) -> Optional[PooledCredential]:
"""After a failed refresh POST: adopt a peer's rotation, quarantine a dead grant, or bench.
Another process may have consumed the refresh token between our
pre-POST sync and the HTTP call; re-read the provider's token
authority once more and adopt fresher tokens before giving up.
"""
if self.provider == "anthropic":
if entry.source == "claude_code":
synced = self._sync_anthropic_entry_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
from agent.anthropic_credentials import refresh_anthropic_oauth_pure
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token, use_json=synced.source.endswith("hermes_pkce"),
)
# Commit to the authoritative singleton BEFORE marking or
# persisting the pool row, or a failed write leaves an
# "ok" row that the next load_pool() re-seeds over.
self._commit_anthropic_rotation(synced, refreshed)
return self._adopt(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
except _RefreshDone as done:
return done.result
except Exception as retry_exc:
logger.debug("Retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
logger.debug("Credentials file has valid token, using without refresh")
return synced
else:
# Backstop for pool-owned sources (hermes_pkce, manual:dashboard_pkce):
# the winner may have persisted between our pre-check and our POST.
synced = self._sync_entry_from_pool_store(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Anthropic OAuth refresh failed but pool store has newer tokens — adopting")
return self._adopt(synced, **_MARK_OK)
from agent.anthropic_credentials import is_terminal_anthropic_refresh_error
if is_terminal_anthropic_refresh_error(exc):
# A dead grant is not "exhausted": benching it for a TTL replays the dead token every
# hour at DEBUG, so the lost login left no trace (#113023). Never touch the external
# CLI's credentials file here — only Hermes' own row goes DEAD.
logger.warning(
"Anthropic OAuth refresh token for %s is terminally invalid (%s); the credential "
"leaves rotation. Re-run 'hermes auth add anthropic' to sign in again.",
entry.label or entry.id[:8], exc)
self._mark_dead_refresh_grant(entry, exc)
return None
elif self.provider in _TOKENS_SINGLETON_PROVIDERS:
_, display, _, terminal_fn_name = _TOKENS_SINGLETON_PROVIDERS[self.provider]
synced = self._sync_entry_from_auth_store(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("%s OAuth refresh failed but auth.json has newer tokens — adopting", display)
return self._adopt(synced, **_MARK_OK)
# Terminal error with no newer tokens: the stored refresh_token is
# dead. Clear it from auth.json so the next session does not
# re-seed the revoked credentials, and drop singleton-seeded
# entries from the pool (mirrors the Nous quarantine path).
if getattr(auth_mod, terminal_fn_name)(exc):
# WARNING, not debug: this is the moment a login is lost. At the default log level a
# silent quarantine looked like "I logged in once and Hermes keeps failing" (#113023).
logger.warning(
"%s OAuth refresh token is terminally invalid (%s); clearing local token state. "
"Re-run 'hermes auth add %s' to sign in again.", display, exc, self.provider)
self._clear_terminal_tokens_state(entry, exc)
self._quarantine_sources(entry, {"device_code"})
self._mark_dead_refresh_grant(entry, exc)
return None
elif self.provider == "nous":
synced = self._sync_nous_entry_from_auth_store(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Nous refresh failed but auth.json has newer tokens — adopting")
updated = self._adopt(synced, **_MARK_OK)
self._sync_device_code_entry_to_auth_store(updated)
return updated
if isinstance(exc, TimeoutError):
# Lost the auth-store lock race under heavy fan-out. That says
# nothing about the credential — benching it here emptied the
# pool for ~120 sessions ("matched no nous entry ... pool size
# 0"). The caller's retry re-syncs once the winner persisted.
logger.debug("Nous refresh skipped: auth store lock busy; not benching entry")
return entry
if auth_mod._is_terminal_nous_refresh_error(exc):
logger.warning(
"Nous refresh token is terminally invalid (%s); clearing local token state. "
"Re-run 'hermes auth add nous' to sign in again.", exc)
self._clear_terminal_nous_state(entry, exc)
self._quarantine_sources(
entry,
{auth_mod.NOUS_DEVICE_CODE_SOURCE, f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}"},
)
self._mark_dead_refresh_grant(entry, exc)
return None
elif plugin_refresh_hook(self.provider) is not None:
handled, result = recover_failed_plugin_refresh(self, entry, exc)
if handled:
return result
self._mark_exhausted(entry, None)
return None
def _mark_dead_refresh_grant(self, entry: PooledCredential, exc: Exception) -> None:
"""Mark a row whose refresh token was terminally rejected DEAD, if the quarantine kept it.
``_quarantine_sources`` drops only singleton-seeded rows; an independent ``manual:*`` login
(``hermes auth add``) survives, and an unmarked survivor re-enters rotation and re-fires the
terminal WARNING on every later refresh attempt. DEAD leaves rotation until a write-side
re-auth sync clears it (never via TTL).
"""
with self._lock:
current = next((item for item in self._entries if item.id == entry.id), None)
if current is None or current.last_status == STATUS_DEAD:
return
self._adopt(
current,
last_status=STATUS_DEAD,
last_status_at=time.time(),
last_error_code=None,
last_error_reason=str(getattr(exc, "code", None) or "invalid_grant"),
last_error_message=str(exc),
)
def _clear_terminal_tokens_state(self, entry: PooledCredential, exc: Exception) -> None:
"""Drop the dead Codex/xAI token pair from auth.json unless a peer already rotated it."""
display = _TOKENS_SINGLETON_PROVIDERS[self.provider][1]
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, self.provider) or {}
tokens = (state.get("tokens") or {}) if isinstance(state, dict) else None
if isinstance(tokens, dict):
store_refresh = str(tokens.get("refresh_token") or "").strip()
if not store_refresh or store_refresh == str(entry.refresh_token or "").strip():
tokens.pop("access_token", None)
tokens.pop("refresh_token", None)
state["tokens"] = tokens
state["last_auth_error"] = {
"provider": self.provider,
"code": getattr(exc, "code", "unknown"),
"message": str(exc),
"reason": "credential_pool_refresh_failure",
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
_save_provider_state(auth_store, self.provider, state)
_save_auth_store(auth_store)
except Exception as clear_exc:
logger.debug("Failed to clear terminal %s OAuth state: %s", display, clear_exc)
def _clear_terminal_nous_state(self, entry: PooledCredential, exc: Exception) -> None:
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous") or {
"client_id": entry.client_id,
"portal_base_url": entry.portal_base_url,
"inference_base_url": entry.inference_base_url,
"token_type": entry.token_type,
"scope": entry.scope,
"tls": entry.tls,
}
store_refresh = str(state.get("refresh_token") or "").strip()
if not store_refresh or store_refresh == str(entry.refresh_token or "").strip():
auth_mod._quarantine_nous_oauth_state(state, exc, reason="credential_pool_refresh_failure")
auth_mod._quarantine_nous_pool_entries(auth_store, exc, reason="credential_pool_refresh_failure")
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
except Exception as clear_exc:
logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc)
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
"""Live-check whether an exhausted Codex entry's quota reset early.
A Codex 429 persists a ``last_error_reset_at`` that can be days out
(weekly windows), but the window can reopen before then (redeemed
reset, plan upgrade, OpenAI reset) — issue #43747. Only fires for
429/quota-shaped errors; the probe is throttled per token (5 min) so
it is safe on the hot selection path.
"""
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
return False
if not auth_mod._is_codex_rate_limit_shaped(
entry.last_error_code, entry.last_error_reason, entry.last_error_message,
):
return False
token = entry.access_token or ""
if not token:
return False
try:
# An exhausted entry is skipped by the refresh chain, so its stored token is usually
# expired by probe time (401 -> None -> cooldown kept, #89415): refresh it first.
fresh = auth_mod._refresh_expired_codex_probe_token(token, entry.refresh_token)
if fresh:
# Persist the rotated pair on both sides the way ``_refresh_entry`` does:
# ``last_refresh`` plus the singleton write-back, or the next selection's
# auth-store sync re-adopts the consumed pair from ``providers.openai-codex``
# and clears the cooldown with it.
entry = self._adopt(
entry, access_token=fresh["access_token"], refresh_token=fresh["refresh_token"],
last_refresh=fresh.get("last_refresh") or entry.last_refresh,
)
self._sync_device_code_entry_to_auth_store(entry)
token = entry.access_token or token
# The row keeps the canonical URL; a gateway key belongs to its route host (#121486).
from hermes_cli.auth_codex import _codex_pool_route_base_url
return bool(auth_mod._probe_codex_quota_restored(
token, base_url=_codex_pool_route_base_url(entry.base_url)))
except Exception:
logger.debug("Codex quota-restored probe failed", exc_info=True)
return False
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
if self.provider == "anthropic":
if entry.expires_at_ms is None:
return False
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
if self.provider == "openai-codex":
return _codex_access_token_is_expiring(entry.access_token, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
if self.provider == "xai-oauth":
return auth_mod._xai_access_token_is_expiring(
entry.access_token, auth_mod._xai_proactive_refresh_skew_seconds(entry.access_token),
)
# Nous refresh can require network access and happens when runtime
# credentials are actually resolved, not on enumeration/selection.
return False
# ---- selection ---------------------------------------------------------
def select(self, *, model: Optional[str] = None) -> Optional[PooledCredential]:
entry, pending_refresh = self._select_under_lock(model=model)
if pending_refresh:
self._refresh_pending_entries(pending_refresh)
# Re-select now that the refreshed entries are back in the pool.
if entry is None:
entry, _ = self._select_under_lock(model=model)
if entry is not None:
self._unmatched_rotation_streak = 0
return entry
def _select_under_lock(self, *, model: Optional[str] = None) -> Tuple[Optional[PooledCredential], List[PooledCredential]]:
with self._lock:
return self._select_unlocked(model=model)
def _refresh_pending_entries(self, pending: List[PooledCredential]) -> None:
"""Refresh deferred single-use-token entries OUTSIDE the pool lock.
Each refresh takes the cross-process ``_auth_store_lock`` (20+ s
possible) and merges into the pool through the self-locking mutation
primitives; failures are silently skipped.
"""
for entry in pending:
self._refresh_entry(entry, force=False)
def _reset_cleared_after(self, entry: PooledCredential) -> Optional[float]:
"""Epoch of a ``hermes auth reset`` persisted by another process AFTER *entry*'s status, else None."""
try:
row = next((p for p in read_credential_pool(self.provider)
if isinstance(p, dict) and p.get("id") == entry.id), None)
cleared = _parse_absolute_timestamp((row or {}).get("status_cleared_at"))
except Exception as exc:
logger.debug("Pool entry %s: could not read reset marker: %s", entry.id, exc)
return None
return cleared if cleared and cleared > (entry.last_status_at or 0.0) else None
def _resync_stale_entry(self, entry: PooledCredential) -> PooledCredential:
"""Re-read an exhausted/DEAD singleton-seeded entry from its token authority.
The user may have re-authed (``hermes model`` / ``hermes auth``, the
Claude Code CLI, another profile) leaving fresh tokens on disk while
the pool entry is frozen behind ``last_error_reset_at``. A ``hermes auth
reset`` run from another process while this pool is live is honoured the
same way (#89415): the in-memory cooldown would otherwise outlive it.
"""
if entry.last_status not in {STATUS_EXHAUSTED, STATUS_DEAD}:
return entry
cleared_at = self._reset_cleared_after(entry)
if cleared_at is not None:
return self._adopt(entry, persist=False, **_MARK_OK, status_cleared_at=cleared_at)
if entry.source != _RESYNC_SOURCE.get(self.provider):
return entry
if self.provider == "anthropic":
return self._sync_anthropic_entry_from_credentials_file(entry)
if self.provider == "nous":
return self._sync_nous_entry_from_auth_store(entry)
return self._sync_entry_from_auth_store(entry)
def _available_entries(
self, *, clear_expired: bool = False, refresh: bool = False, model: Optional[str] = None,
) -> Tuple[List[PooledCredential], List[PooledCredential]]:
"""Return (available, pending_refresh) for entries not in cooldown.
*clear_expired* resets elapsed cooldowns to STATUS_OK and persists.
*refresh* refreshes entries needing a token refresh (skipped on
failure) — except single-use-token providers (openai-codex,
xai-oauth), which are returned as *pending_refresh* so the caller
refreshes them outside the lock instead of stalling every pool
consumer during cross-process flock acquisition + OAuth network I/O.
"""
now = time.time()
cleared_any = False
entries_to_prune: List[str] = []
available: List[PooledCredential] = []
pending_refresh: List[PooledCredential] = []
sole_credential = self._is_sole_credential()
for entry in self._entries:
# Borrowed credentials persist as metadata-only references and are
# hydrated from their live source on load; never lease an
# unhydrated duplicate as an empty key.
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
continue
synced = self._resync_stale_entry(entry)
if synced is not entry:
entry = synced
cleared_any = True
if entry.last_status == STATUS_DEAD:
# Manual DEAD credentials are pruned after a 24h quiet window;
# singleton-seeded ones stay (audit trail, and the seeder would
# re-create them anyway). DEAD never re-enters via TTL — only a
# write-side re-auth sync clears it.
if _is_manual_source(entry.source):
dead_at = entry.last_status_at or 0
if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS:
logger.warning(
"credential pool: pruning DEAD manual entry %s "
"(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`",
entry.label or entry.id[:8],
entry.last_error_reason or "unknown",
(now - dead_at) / 3600.0,
self.provider,
)
entries_to_prune.append(entry.id) # can't mutate while iterating
cleared_any = True
continue
if model_cooldown_until(entry, model) is not None:
continue
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry, sole_credential=sole_credential)
# Codex quota windows can reopen EARLY; a throttled live probe
# lifts a stale cooldown (issue #43747).
if (
exhausted_until is not None
and now < exhausted_until
and not (clear_expired and self._codex_quota_restored_upstream(entry))
):
continue
if clear_expired:
entry = self._adopt(entry, persist=False, **_MARK_OK)
cleared_any = True
if refresh and self._entry_needs_refresh(entry):
if self.provider in _TOKENS_SINGLETON_PROVIDERS:
pending_refresh.append(entry)
continue
refreshed = self._refresh_entry(entry, force=False)
if refreshed is None:
continue
entry = refreshed
if entry.auth_type == AUTH_TYPE_OAUTH and not (entry.access_token or "").strip():
# A borrowed OAuth row that failed to hydrate (or a sanitized
# row read straight off disk); leasing it would send an empty
# bearer. The API-key guard above does not cover it.
continue
available.append(entry)
if entries_to_prune:
pruned_ids = set(entries_to_prune)
self._entries = [e for e in self._entries if e.id not in pruned_ids]
if cleared_any:
self._persist(removed_ids=entries_to_prune)
return available, pending_refresh
def _log_no_available_entries(self) -> None:
"""Emit the empty-pool INFO line at most once per throttle window."""
now = time.monotonic()
last = self._last_no_entries_log_at
if last is not None and (now - last) < NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS:
return
self._last_no_entries_log_at = now
logger.info("credential pool: no available entries (all exhausted or empty)")
def _select_unlocked(
self, *, refresh: bool = True, count: bool = True, model: Optional[str] = None,
) -> Tuple[Optional[PooledCredential], List[PooledCredential]]:
"""Select the best available entry; returns ``(entry, pending_refresh)``.
``count=False`` skips the ``request_count`` bump for selections that are
not going to serve a request (a forced-refresh target lookup).
"""
available, pending_refresh = self._available_entries(clear_expired=True, refresh=refresh, model=model)
if not available:
self._current_id = None
self._log_no_available_entries()
return None, pending_refresh
# The pool recovered; re-arm the throttle so a later re-exhaustion
# logs immediately.
self._last_no_entries_log_at = None
if self._strategy == STRATEGY_RANDOM:
entry = random.choice(available)
elif self._strategy == STRATEGY_LEAST_USED and len(available) > 1:
entry = min(available, key=lambda e: e.request_count)
else:
entry = available[0]
# Count the selection under every strategy. The counter is ``least_used``'s
# baseline and reaches auth.json on the next persist (exhaustion, rotation,
# refresh); it used to move only while ``least_used`` was active.
if count:
entry = self._adopt(entry, persist=False, request_count=entry.request_count + 1)
if self._strategy == STRATEGY_ROUND_ROBIN and len(available) > 1:
rotated = [candidate for candidate in self._entries if candidate.id != entry.id]
rotated.append(replace(entry, priority=len(self._entries) - 1))
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
self._persist()
entry = self._find(lambda candidate: candidate.id == entry.id) or entry
self._current_id = entry.id
return entry, pending_refresh
def peek(self) -> Optional[PooledCredential]:
with self._lock:
current = self._current_unlocked()
if current is not None:
return current
available, _pending = self._available_entries()
return available[0] if available else None
def reclaim(self, credential_id: str, *, model: Optional[str] = None) -> Optional[PooledCredential]:
"""Entry *credential_id* once its cooldown has lifted (cleared and token-refreshed the way
``select`` would), else ``None``. Never bumps ``request_count`` or round-robin order: a
live session asking "may I go back?" every turn is not a request."""
with self._lock:
available, pending = self._available_entries(clear_expired=True, refresh=True, model=model)
if any(e.id == credential_id for e in pending):
self._refresh_pending_entries([e for e in pending if e.id == credential_id])
with self._lock:
available, _pending = self._available_entries(clear_expired=True, refresh=True, model=model)
return next((e for e in available if e.id == credential_id), None)
# ---- rotation ----------------------------------------------------------
def _identify_failed_entry(
self, credential_id: Optional[str], api_key_hint: Optional[str],
) -> Optional[PooledCredential]:
"""Resolve the entry that issued a failed request from its supplied identity."""
entry = None
if credential_id:
entry = self._find(lambda e: e.id == credential_id)
# #79156: when both identities disagree, trust the key that made
# the request. A stale ``_credential_pool_entry_id`` (per-turn env
# refresh rewrote ``api_key`` without rebinding the id) would
# otherwise quarantine a healthy fallback for days.
if entry is not None and api_key_hint and entry.runtime_api_key != api_key_hint:
hint_entry = self._find(lambda e: e.runtime_api_key == api_key_hint)
if hint_entry is not None:
logger.info(
"credential pool: credential_id %s runtime key "
"does not match api_key_hint; attributing failure "
"to key-matched entry %s instead (#79156)",
(entry.label or entry.id[:8]),
(hint_entry.label or hint_entry.id[:8]),
)
# Otherwise the id is stale and the request key is not in the
# pool — drop the id so we do not mark the wrong entry.
entry = hint_entry
if entry is None and api_key_hint:
# Prefer the entry whose key actually failed: on a pool freshly
# loaded from disk current() is None and _select_unlocked() would
# return the NEXT key — the wrong one.
entry = self._find(lambda e: e.runtime_api_key == api_key_hint)
return entry
def _rotate_unmatched(self) -> Optional[PooledCredential]:
"""Rotate without marking anything when the failed identity matches no entry.
Falling through to current()/_select_unlocked() would bench an
innocent healthy key for the full TTL. But this must be BOUNDED
(#70401): with OAuth-token auth the 401's key hint never matches any
``runtime_api_key``, so every retry lands here, nothing is marked, and
the caller retries the same dead token forever (~6/sec, starving the
event loop). Cap consecutive no-mark rotations at one lap of the
available entries, then surface the error; no cooldown is written.
"""
self._unmatched_rotation_streak += 1
available_count = len(self._available_entries()[0])
if self._unmatched_rotation_streak > max(available_count, 1):
logger.warning(
"credential pool: failed credential identity matched no "
"%s entry for %d consecutive rotations (pool size %d) — "
"surfacing the error instead of rotating again",
self.provider, self._unmatched_rotation_streak, available_count,
)
self._unmatched_rotation_streak = 0
self._current_id = None
return None
logger.info(
"credential pool: failed credential identity matched no %s "
"entry; rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
next_entry, _pending = self._select_unlocked(refresh=False)
if next_entry is not None and len(self._available_entries()[0]) == 1:
# A single-entry pool cannot rotate: returning its only entry would
# report a recovery without changing the credential, and the
# caller retries the same 401 indefinitely.
self._unmatched_rotation_streak = 0
self._current_id = None
return None
return next_entry
def mark_exhausted_and_rotate(
self,
*,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
failure_reason: Optional[str] = None,
model: Optional[str] = None,
) -> Optional[PooledCredential]:
with self._lock:
identity_supplied = bool(credential_id or api_key_hint)
entry = self._identify_failed_entry(credential_id, api_key_hint)
if entry is None and identity_supplied:
return self._rotate_unmatched()
# A real entry was identified — any prior unmatched streak is stale.
self._unmatched_rotation_streak = 0
if entry is None:
entry = self._current_unlocked() or self._select_unlocked(refresh=False)[0]
if entry is None:
return None
_label = entry.label or entry.id[:8]
if self._is_model_scoped_failure(status_code, model, failure_reason):
# A generic Anthropic 429 (per-model rate limit) or a Codex account model
# entitlement rejection: bench this model only, the credential stays
# available for its siblings.
self._cool_down_model(entry, model, error_context, failure_reason=failure_reason)
logger.info("credential pool: %s unavailable for model %s; other models stay available", _label, model)
self._current_id = None
next_entry, _pending = self._select_unlocked(refresh=False, model=model)
return next_entry
self._mark_exhausted(entry, status_code, error_context, failure_reason=failure_reason)
# A 402/429/401 is a key-level failure, and the same key can back
# several entries (an explicit entry plus a ``model_config`` row
# auto-seeded from ``model.api_key``). Marking only the first
# leaves siblings OK, ``_select_unlocked()`` keeps handing back
# the depleted key, and rotation never converges (~2.5 min hang).
# Mark every entry sharing the failed key.
failed_runtime_key = entry.runtime_api_key
if identity_supplied and failed_runtime_key:
siblings = [
s for s in self._entries if s.id != entry.id and s.runtime_api_key == failed_runtime_key
]
for sibling in siblings:
self._mark_exhausted(
sibling, status_code, error_context, persist=False, failure_reason=failure_reason,
)
if siblings:
self._persist()
# Re-read the updated entry to log the correct terminal state.
updated_entry = self._find(lambda e: e.id == entry.id) or entry
if updated_entry.last_status == STATUS_DEAD:
logger.warning(
"credential pool: marking %s DEAD (status=%s, reason=%s) — "
"permanently failed, will NOT re-enter rotation until re-auth",
_label, status_code, updated_entry.last_error_reason or "unknown",
)
else:
logger.info("credential pool: marking %s exhausted (status=%s), rotating", _label, status_code)
self._current_id = None
next_entry, _pending = self._select_unlocked(refresh=False)
if next_entry is not None and next_entry.id == entry.id:
# No-recovery guard (#97315): selection handed back the very entry that was
# just marked (the auth-store sync adopted fresher tokens, or a quota probe
# false-positive lifted the bench mid-selection). Returning it reports a
# successful rotation without changing the credential, so the caller retries
# the same 429 forever (~2 req/s for hours). Mirror the single-entry guard on
# the unmatched-identity branch: surface the failure instead.
logger.warning(
"credential pool: rotation returned the just-marked entry %s — "
"treating as no-recovery so the failure surfaces", _label,
)
self._current_id = None
return None
if next_entry:
logger.info("credential pool: rotated to %s", next_entry.label or next_entry.id[:8])
return next_entry
# ---- leases ------------------------------------------------------------
def acquire_lease(self, credential_id: Optional[str] = None) -> Optional[str]:
"""Acquire a soft lease on a credential.
With *credential_id*, lease that entry directly. Otherwise prefer the
least-leased available credential (priority as tie-breaker); when
every credential is at the soft cap, still return the least-leased
one instead of blocking.
"""
chosen_id, pending_refresh = self._acquire_lease_under_lock(credential_id)
if pending_refresh:
self._refresh_pending_entries(pending_refresh)
# Mirror select(): a pool whose entries all needed a deferred
# refresh must retry once they are back in rotation, or the caller
# sees "no credentials available" after a successful refresh.
if chosen_id is None:
chosen_id, _ = self._acquire_lease_under_lock(credential_id)
return chosen_id
def _acquire_lease_under_lock(
self, credential_id: Optional[str],
) -> Tuple[Optional[str], List[PooledCredential]]:
with self._lock:
if credential_id:
self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1
self._current_id = credential_id
return credential_id, []
available, pending_refresh = self._available_entries(clear_expired=True, refresh=True)
if not available:
return None, pending_refresh
below_cap = [e for e in available if self._active_leases.get(e.id, 0) < self._max_concurrent]
chosen = min(
below_cap or available,
key=lambda entry: (self._active_leases.get(entry.id, 0), entry.priority),
)
self._active_leases[chosen.id] = self._active_leases.get(chosen.id, 0) + 1
self._current_id = chosen.id
return chosen.id, pending_refresh
def release_lease(self, credential_id: str) -> None:
with self._lock:
count = self._active_leases.get(credential_id, 0)
if count <= 1:
self._active_leases.pop(credential_id, None)
else:
self._active_leases[credential_id] = count - 1
# ---- explicit refresh / admin ------------------------------------------
def try_refresh_current(self) -> Optional[PooledCredential]:
with self._lock:
return self._try_refresh_current_unlocked()
def try_refresh_matching(
self,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
) -> Optional[PooledCredential]:
"""Force-refresh the entry that supplied the failed request.
Direct integrations may reload the pool after a request failed, so
``current_id`` cannot identify the issuing credential. With no hint,
select WITHOUT the normal proactive refresh: the forced refresh below
must consume a rotating refresh token exactly once.
"""
with self._lock:
entry = self._find(lambda e: e.id == credential_id) if credential_id else None
if entry is None:
if api_key_hint:
entry = self._find(lambda e: e.runtime_api_key == api_key_hint)
else:
entry = self._current_unlocked() or self._select_unlocked(refresh=False, count=False)[0]
if entry is None:
return None
self._current_id = entry.id
return self._try_refresh_current_unlocked()
def _try_refresh_current_unlocked(self) -> Optional[PooledCredential]:
entry = self._current_unlocked()
if entry is None:
return None
refreshed = self._refresh_entry(entry, force=True)
if refreshed is not None:
self._current_id = refreshed.id
return refreshed
# --- Seeding --------------------------------------------------------------
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
matching_indices = [idx for idx, entry in enumerate(entries) if entry.source == source]
existing_idx = matching_indices[0] if matching_indices else None
duplicate_indices = set(matching_indices[1:])
if duplicate_indices:
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
if existing_idx is None:
payload.setdefault("id", uuid.uuid4().hex[:6])
payload.setdefault("priority", _next_priority(entries))
payload.setdefault("label", payload.get("label") or source)
entries.append(PooledCredential.from_dict(provider, payload))
return True
existing = entries[existing_idx]
field_updates: Dict[str, Any] = {}
extra_updates: Dict[str, Any] = {}
_field_names = {f.name for f in fields(existing)}
incoming_token = payload.get("access_token")
token_changed = incoming_token is not None and incoming_token != existing.access_token
if token_changed and not existing.access_token:
# Borrowed sources (claude_code, env-backed rows) are written to
# auth.json without their secret, so a reloaded entry carries only a
# ``secret_fingerprint``. Comparing against the empty string reported
# a rotation on EVERY load and cleared the DEAD/exhausted state the
# previous process had just persisted. Compare fingerprints instead.
known_fingerprint = existing.extra.get("secret_fingerprint")
if isinstance(known_fingerprint, str) and known_fingerprint:
token_changed = fingerprint_secret_value(incoming_token) != known_fingerprint
for key, value in payload.items():
if key in {"id", "priority"} or value is None or (key == "label" and existing.label):
continue
if key in _field_names:
if getattr(existing, key) != value:
field_updates[key] = value
elif key in _EXTRA_KEYS and existing.extra.get(key) != value:
extra_updates[key] = value
# A rotated token makes the old exhaustion/error state stale.
if token_changed and existing.last_status is not None:
field_updates.update(_CLEAR_STATUS)
if field_updates or extra_updates:
if extra_updates:
field_updates["extra"] = {**existing.extra, **extra_updates}
updated = replace(existing, **field_updates)
entries[existing_idx] = updated
# Runtime-only borrowed secret updates refresh the in-memory entry
# without forcing auth.json churn when the disk-safe payload is
# unchanged (e.g. env keys with the same fingerprint).
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
return bool(duplicate_indices)
_ANTHROPIC_SOURCE_RANK = {
"env:ANTHROPIC_TOKEN": 0,
"env:CLAUDE_CODE_OAUTH_TOKEN": 1,
"hermes_pkce": 2,
"claude_code": 3,
"env:ANTHROPIC_API_KEY": 4,
}
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
if provider != "anthropic":
return False
manual_entries = sorted(
(entry for entry in entries if _is_manual_source(entry.source)),
key=lambda entry: entry.priority,
)
seeded_entries = sorted(
(entry for entry in entries if not _is_manual_source(entry.source)),
key=lambda entry: (
_ANTHROPIC_SOURCE_RANK.get(entry.source, len(_ANTHROPIC_SOURCE_RANK)),
entry.priority,
entry.label,
),
)
id_to_idx = {entry.id: idx for idx, entry in enumerate(entries)}
changed = False
for new_priority, entry in enumerate([*manual_entries, *seeded_entries]):
if entry.priority != new_priority:
entries[id_to_idx[entry.id]] = replace(entry, priority=new_priority)
changed = True
return changed
def _retain_sources_not_in(entries: List[PooledCredential], drop: Set[str]) -> bool:
"""Remove entries whose source is in *drop*; True if anything was removed."""
retained = [entry for entry in entries if entry.source not in drop]
if len(retained) == len(entries):
return False
entries[:] = retained
return True
class _Seeder:
"""Accumulates ``_upsert_entry`` results for one ``load_pool`` seeding pass."""
def __init__(self, provider: str, entries: List[PooledCredential]):
self.provider = provider
self.entries = entries
self.changed = False
self.active_sources: Set[str] = set()
self.is_suppressed = _is_source_suppressed_fn()
def upsert(self, source: str, payload: Dict[str, Any]) -> bool:
"""Upsert unless suppressed (``hermes auth remove`` must stay stable across loads)."""
if self.is_suppressed(self.provider, source):
return False
self.active_sources.add(source)
ingested = _upsert_entry(self.entries, self.provider, source, {"source": source, **payload})
self.changed |= ingested
return ingested
@property
def result(self) -> Tuple[bool, Set[str]]:
return self.changed, self.active_sources
def _seed_anthropic_singletons(seed: _Seeder) -> None:
# Only auto-discover external credentials (Claude Code, Hermes PKCE) when
# the user explicitly configured anthropic; otherwise auxiliary fallback
# chains would read ~/.claude/.credentials.json without consent (PR #4210).
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return
except ImportError:
pass
# API-key vs OAuth is a user-visible choice at `hermes setup`. The API-key
# signal is ANTHROPIC_API_KEY set AND no OAuth env vars (the save_* helpers
# zero the other side). Then we MUST NOT seed autodiscovered OAuth tokens:
# rotation on a 401/429 would silently flip the session onto OAuth, which
# forces the Claude Code identity injection, `mcp_` tool-name rewrite and
# claude-cli User-Agent the user explicitly opted out of. Prefer
# ~/.hermes/.env over os.environ, as `_seed_from_env` does.
_env_file = load_env()
def _env_val(key: str) -> str:
return (_env_file.get(key) or _get_secret(key, "") or "").strip()
anthropic_oauth_env = _env_val("ANTHROPIC_TOKEN") or _env_val("CLAUDE_CODE_OAUTH_TOKEN")
if _env_val("ANTHROPIC_API_KEY") and not anthropic_oauth_env:
# Prune stale autodiscovered OAuth entries from a previous OAuth
# session so a transient 401 cannot revive them.
seed.changed |= _retain_sources_not_in(seed.entries, {"hermes_pkce", "claude_code"})
return
from agent.anthropic_credentials import (
read_claude_code_credentials,
read_hermes_oauth_credentials,
)
from agent.credential_sources import adopt_external_logins_enabled
sources = [("hermes_pkce", read_hermes_oauth_credentials())]
if adopt_external_logins_enabled():
sources.append(("claude_code", read_claude_code_credentials()))
else:
# Singleton-seeded rows are otherwise never pruned; the opt-out must also drop the row an
# earlier (adopting) process persisted, or it keeps rotating a login Hermes no longer reads.
seed.changed |= _retain_sources_not_in(seed.entries, {"claude_code"})
for source_name, creds in sources:
if creds and creds.get("accessToken"):
seed.upsert(source_name, {
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at_ms": creds.get("expiresAt"),
"label": label_from_token(creds.get("accessToken", ""), source_name),
})
def _seed_nous_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
state, source_path = _load_provider_state_with_source(auth_store, "nous")
global_root = _global_auth_file_path()
if (
source_path is not None and global_root is not None and _same_path(source_path, global_root)
and _store_owns_pool_provider(auth_store, "nous")
):
# A profile that owns local nous rows (e.g. an agent_key-only row surviving a
# fork strip/heal) must not re-seed root's single-use refresh token into its
# own pool from the global-root fallback: that re-creates the fork.
return
has_runtime_material = bool(
isinstance(state, dict)
and (str(state.get("access_token") or "").strip() or str(state.get("agent_key") or "").strip())
)
if state and not has_runtime_material:
seed.changed |= _retain_sources_not_in(seed.entries, {"device_code", "manual:device_code"})
if not (state and has_runtime_material):
return
# Prefer a user-supplied label embedded in the singleton state (``hermes
# auth add nous --label <name>``) over the token-derived fingerprint.
custom_label = str(state.get("label") or "").strip()
seed.upsert("device_code", {
"auth_type": AUTH_TYPE_OAUTH,
"access_token": state.get("access_token", ""),
"refresh_token": state.get("refresh_token"),
"expires_at": state.get("expires_at"),
"token_type": state.get("token_type"),
"scope": state.get("scope"),
"client_id": state.get("client_id"),
"portal_base_url": state.get("portal_base_url"),
"inference_base_url": state.get("inference_base_url"),
"agent_key": state.get("agent_key"),
"agent_key_expires_at": state.get("agent_key_expires_at"),
# Refresh timestamps let freshness-sensitive consumers (self-heal
# hooks, pruning by age) tell just-refreshed credentials from stale
# ones (#15099).
**{key: state.get(key) for key in _NOUS_EXTRA_STATE_KEYS},
"tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
"label": custom_label or label_from_token(state.get("access_token", ""), "device_code"),
})
# Warn once per token per process when Copilot exchange degrades to raw token (#114740).
_COPILOT_RAW_DEGRADATION_WARNED: Set[str] = set()
def _warn_copilot_raw_degradation_once(token: str) -> None:
"""WARN once per token per process when Copilot exchange degrades to raw token (#114740)."""
fingerprint = fingerprint_secret_value(token) or "unknown"
if fingerprint in _COPILOT_RAW_DEGRADATION_WARNED:
return
_COPILOT_RAW_DEGRADATION_WARNED.add(fingerprint)
logger.warning(
"Copilot token exchange degraded to RAW token (exchange "
"unavailable); enterprise-only models may 400 with "
"model_not_available_for_integrator until exchange recovers."
)
def _reset_copilot_raw_degradation_warned() -> None:
"""Clear the degradation warning cache (for test isolation)."""
_COPILOT_RAW_DEGRADATION_WARNED.clear()
def _seed_copilot_singleton(seed: _Seeder) -> None:
# Copilot tokens are resolved dynamically via `gh auth token` or env vars
# (COPILOT_GITHUB_TOKEN / GH_TOKEN); they don't live in the auth store.
try:
from hermes_cli.copilot_auth import (
COPILOT_ENV_VARS,
resolve_copilot_token,
get_copilot_api_token,
)
# All-sources gate BEFORE any work: resolve_copilot_token() shells out
# and the exchange retries 3x with backoff (~35s worst case); a user
# who suppressed every copilot source must not pay that on every pool
# load. The source space here matches credential_sources._remove_copilot_gh.
copilot_sources = ["gh_cli"] + [f"env:{v}" for v in COPILOT_ENV_VARS]
if all(seed.is_suppressed(seed.provider, s) for s in copilot_sources):
return
token, source = resolve_copilot_token()
if not token:
return
# Exact match: a substring test would classify GH_TOKEN/GITHUB_TOKEN
# as gh_cli and bypass a user's per-env-var suppression.
source_name = "gh_cli" if source == "gh auth token" else f"env:{source}"
# Per-source gate BEFORE the (~35s worst case) network exchange.
if seed.is_suppressed(seed.provider, source_name):
return
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured(seed.provider):
# Copilot is only discovered here (ambient gh CLI login), not selected anywhere: no
# model will be routed to it, so the network exchange — and its degradation warning on
# every pool load — buys nothing (#114740). Seed the raw token; the load that follows
# the user selecting copilot re-seeds and exchanges.
api_token, enterprise_base_url = token, None
else:
api_token, enterprise_base_url = get_copilot_api_token(token)
# get_copilot_api_token falls back to the RAW token when the exchange
# fails; the Copilot API then routes it to the fallback
# "copilot-language-server" integrator whose allowlist omits
# enterprise-only models -> HTTP 400 on every turn. Surface it once.
if api_token == token and not enterprise_base_url:
_warn_copilot_raw_degradation_once(token)
pconfig = PROVIDER_REGISTRY.get(seed.provider)
seed.upsert(source_name, {
"auth_type": AUTH_TYPE_API_KEY,
"access_token": api_token,
"base_url": enterprise_base_url or (pconfig.inference_base_url if pconfig else ""),
"label": source,
})
except Exception as exc:
logger.debug("Copilot token seed failed: %s", exc)
def _seed_qwen_singleton(seed: _Seeder) -> None:
# Qwen OAuth tokens live in ~/.qwen/oauth_creds.json (written by the Qwen
# CLI). refresh_if_expiring=False avoids network calls during pool loading.
try:
from hermes_cli.auth import resolve_qwen_runtime_credentials
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
token = creds.get("api_key", "")
if token:
source_name = creds.get("source", "qwen-cli")
seed.upsert(source_name, {
"auth_type": AUTH_TYPE_OAUTH,
"access_token": token,
"expires_at_ms": creds.get("expires_at_ms"),
"base_url": creds.get("base_url", ""),
"label": creds.get("auth_file", source_name),
})
except Exception as exc:
logger.debug("Qwen OAuth token seed failed: %s", exc)
def _seed_minimax_singleton(seed: _Seeder) -> None:
# Read the raw auth.json state rather than resolve_minimax_oauth_runtime_credentials,
# which always refreshes on expiry (surprise network calls during discovery).
try:
from hermes_cli.auth import get_provider_auth_state
state = get_provider_auth_state("minimax-oauth")
if not (state and state.get("access_token")):
return
expires_at_ms = None
try:
raw = state.get("expires_at", "")
if raw:
expires_at_ms = int(datetime.fromisoformat(raw).timestamp() * 1000)
except Exception:
expires_at_ms = None
seed.upsert("oauth", {
"auth_type": AUTH_TYPE_OAUTH,
"access_token": state["access_token"],
"refresh_token": state.get("refresh_token"),
"expires_at_ms": expires_at_ms,
"base_url": str(state.get("inference_base_url", "") or "").rstrip("/"),
"label": state.get("label", "") or label_from_token(state.get("access_token", ""), "oauth"),
})
except Exception as exc:
logger.debug("MiniMax OAuth token seed failed: %s", exc)
def _seed_tokens_singleton(seed: _Seeder, auth_store: Dict[str, Any]) -> None:
"""Codex / xAI: surface the auth.json ``providers.<id>.tokens`` singleton as ``device_code``.
Hermes owns its own Codex auth state and does NOT auto-import
~/.codex/auth.json: refresh tokens are single-use, so sharing them with
Codex CLI / VS Code causes refresh_token_reused races. Adoption is an
explicit one-time prompt via `hermes auth openai-codex`.
"""
state = _load_provider_state(auth_store, seed.provider)
tokens = state.get("tokens") if isinstance(state, dict) else None
if not (isinstance(tokens, dict) and tokens.get("access_token")):
return
if seed.provider == "openai-codex":
base_url = auth_mod.DEFAULT_CODEX_BASE_URL
custom_label = str(state.get("label") or "").strip()
else:
base_url = auth_mod.DEFAULT_XAI_OAUTH_BASE_URL
custom_label = ""
seed.upsert("device_code", {
"auth_type": AUTH_TYPE_OAUTH,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token"),
"base_url": base_url,
"last_refresh": state.get("last_refresh"),
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
})
def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
seed = _Seeder(provider, entries)
auth_store = _load_auth_store()
if provider == "anthropic":
_seed_anthropic_singletons(seed)
elif provider == "nous":
_seed_nous_singleton(seed, auth_store)
elif provider == "copilot":
_seed_copilot_singleton(seed)
elif provider == "qwen-oauth":
_seed_qwen_singleton(seed)
elif provider == "minimax-oauth":
_seed_minimax_singleton(seed)
elif provider in _TOKENS_SINGLETON_PROVIDERS:
# `hermes auth remove openai-codex` suppresses device_code; without
# this gate the removal is undone on the next load_pool().
if provider == "openai-codex" and seed.is_suppressed(provider, "device_code"):
return seed.result
_seed_tokens_singleton(seed, auth_store)
return seed.result
def get_env_prefer_dotenv(key: str) -> str:
"""Resolve a credential env var, preferring ~/.hermes/.env over os.environ.
The user's config file is authoritative; stale env vars from parent
processes (Codex CLI, test scripts) must not override deliberate .env
changes. load_env() memoizes on mtime, so per-call reads cost a stat().
An unresolved ``op://`` reference in .env yields to the already-resolved
value from the active secret scope (set by apply_onepassword_secrets());
otherwise every provider auth attempt would receive a URL instead of a key.
"""
env_file = load_env()
raw = env_file.get(key, "").strip()
scoped_value = (_get_secret(key, "") or "").strip()
if raw.startswith("op://") and scoped_value:
return scoped_value
return raw or scoped_value
# Providers already warned about env-key -> pool ingestion, once per process
# (#81952 expected-behavior #3).
_ENV_INGESTION_WARNED: Set[str] = set()
def _warn_env_ingestion_once(provider: str, env_var: str) -> None:
"""WARN once per process per provider when an env credential is ingested into a paid pool.
Auto-ingesting OPENROUTER_API_KEY is what ARMS silent OpenRouter spend —
every downstream auto-detect keys off the pool having credentials.
Ingestion stays allowed (an exported key is arguable intent) but must
never be silent.
"""
if provider in _ENV_INGESTION_WARNED:
return
_ENV_INGESTION_WARNED.add(provider)
logger.warning(
"Ingested %s from environment into the %s credential pool — this "
"enables %s spend. Remove the key or run "
"hermes auth remove %s <n> to suppress.",
env_var,
provider,
"OpenRouter" if provider == "openrouter" else provider,
provider,
)
def _env_payload(*, env_var: str, token: str, base_url: str) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"auth_type": AUTH_TYPE_API_KEY,
"access_token": token,
"base_url": base_url,
"label": env_var,
}
try:
from hermes_cli.env_loader import get_secret_source
source_label = get_secret_source(env_var)
except Exception:
source_label = None
secret_source = str(source_label).strip() if source_label else None
if secret_source:
payload["secret_source"] = secret_source
return payload
# Region-specific endpoints inferred from the key itself.
_ENV_BASE_URL_RESOLVERS = {
"kimi-coding": _resolve_kimi_base_url,
"zai": _resolve_zai_base_url,
}
def _env_key_var_candidates(env_vars: List[str], entries: List[PooledCredential]) -> List[str]:
"""*env_vars*, their numbered siblings, and the ``env:VAR`` names already persisted.
``VAR_2``, ``VAR_3``, ... are tried for every declared VAR until the first
one that does not resolve, so a `.env` or secret-manager project can back a
whole rotation pool with no config: setting ``NVIDIA_API_KEY_2`` is the
whole opt-in (#76593).
Env-backed rows are written to auth.json without their secret and
re-hydrated on every load; a row whose VAR the registry does not
declare would otherwise stay empty forever and be silently dropped
from rotation by ``_available_entries``.
"""
names = list(env_vars)
for base in env_vars:
n = 2
while get_env_prefer_dotenv(f"{base}_{n}"):
names.append(f"{base}_{n}")
n += 1
for entry in entries:
if entry.source.startswith("env:"):
env_name = entry.source.split(":", 1)[1].strip()
if env_name and env_name not in names:
names.append(env_name)
return names
def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
seed = _Seeder(provider, entries)
# Copilot's singleton branch exchanges the raw ghu_ OAuth token for the
# api token via `get_copilot_api_token`; the generic loop would re-read
# COPILOT_GITHUB_TOKEN and overwrite it with the RAW token, causing 400s
# ("not available for integrator copilot-language-server").
if provider == "copilot":
return seed.result
if provider == "openrouter":
for env_var in _env_key_var_candidates(["OPENROUTER_API_KEY"], entries):
token = get_env_prefer_dotenv(env_var)
if token and seed.upsert(
f"env:{env_var}",
_env_payload(env_var=env_var, token=token, base_url=OPENROUTER_BASE_URL),
):
_warn_env_ingestion_once(provider, env_var)
return seed.result
pconfig = PROVIDER_REGISTRY.get(provider)
if not pconfig or pconfig.auth_type != AUTH_TYPE_API_KEY:
return seed.result
env_url = ""
if pconfig.base_url_env_var:
env_url = get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
env_vars = list(pconfig.api_key_env_vars)
if provider == "anthropic":
env_vars = ["ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
env_vars = _env_key_var_candidates(env_vars, entries)
resolve_base_url = _ENV_BASE_URL_RESOLVERS.get(provider)
for env_var in env_vars:
token = get_env_prefer_dotenv(env_var)
if not token:
continue
base_url = env_url or pconfig.inference_base_url
if resolve_base_url is not None:
base_url = resolve_base_url(token, pconfig.inference_base_url, env_url)
seed.upsert(f"env:{env_var}", _env_payload(env_var=env_var, token=token, base_url=base_url))
return seed.result
def _prune_stale_seeded_entries(
entries: List[PooledCredential],
active_sources: Set[str],
*,
prune_env_sources: bool = True,
) -> bool:
def _is_prunable(entry: PooledCredential) -> bool:
# ``env:*`` entries are persisted references re-hydrated on every load.
# A process that merely lacks the env var must NOT delete the on-disk
# entry for every other process (#9331); prune only when explicitly
# requested (an `hermes auth` command that confirmed the source is gone).
if entry.source.startswith("env:"):
return prune_env_sources
# File-backed singletons and Hermes PKCE disappear when their backing file is gone.
return is_borrowed_credential_source(entry.source, entry.provider) or entry.source == "hermes_pkce"
retained = [
entry
for entry in entries
if _is_manual_source(entry.source) or entry.source in active_sources or not _is_prunable(entry)
]
if len(retained) == len(entries):
return False
entries[:] = retained
return True
def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
"""Seed a custom endpoint pool from custom_providers config and model config."""
seed = _Seeder(pool_key, entries)
cp_config = _get_custom_provider_config(pool_key)
if cp_config:
api_key = str(cp_config.get("api_key") or "").strip()
name = str(cp_config.get("name") or "").strip()
if api_key:
seed.upsert(f"config:{name}", {
"auth_type": AUTH_TYPE_API_KEY,
"access_token": api_key,
"base_url": _norm_url(cp_config.get("base_url")),
"label": name or f"config:{name}",
})
# Seed from model.api_key when model.provider=='custom' and model.base_url matches
try:
config = _load_config_safe()
model_cfg = config.get("model") if config else None
if isinstance(model_cfg, dict):
model_provider = str(model_cfg.get("provider") or "").strip().lower()
model_base_url = _norm_url(model_cfg.get("base_url"))
model_api_key = next(
(v.strip() for k in ("api_key", "api") for v in (model_cfg.get(k),) if isinstance(v, str) and v.strip()),
"",
)
if model_provider == "custom" and model_base_url and model_api_key:
# The pool may be keyed under the durable ``providers.<key>``
# slug or legacy ``custom:<name>``; accept any candidate, or
# seeding is skipped when the pool holds the other identity.
# Check if this model's base_url matches our custom provider. See #100413.
matched_keys = {
str(key).strip().lower() for key in custom_provider_pool_key_candidates(model_base_url)
}
if pool_key in matched_keys:
seed.upsert("model_config", {
"auth_type": AUTH_TYPE_API_KEY,
"access_token": model_api_key,
"base_url": model_base_url,
"label": "model_config",
})
except Exception:
pass
return seed.result
def load_pool(provider: str) -> CredentialPool:
provider = (provider or "").strip().lower()
if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS:
# One-time heal for installs that forked this grant across profiles
# before the clone-strip / root write-through existed (#100339).
auth_mod.heal_forked_single_use_oauth_grants(provider)
raw_entries = read_credential_pool(provider)
disk_ids = {e.get("id") for e in raw_entries if isinstance(e, dict) and e.get("id")}
# Ownership (auth.json read) after the heal above; re-read at the tail only if _persist() ran.
owns_provider: Optional[bool] = None
changed = any(
isinstance(payload, dict) and sanitize_borrowed_credential_payload(payload, provider) != payload
for payload in raw_entries
)
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
raw_needs_auth_normalization = any(
isinstance(payload, dict)
and _normalize_pool_auth_type(
provider, payload.get("access_token"), payload.get("auth_type", AUTH_TYPE_API_KEY),
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
for payload in raw_entries
)
if raw_needs_auth_normalization:
# A profile may be reading this provider from the global-root fallback.
# Keep that fallback read-only: only the owning store may rewrite these
# rows; loading the default/root profile heals global rows.
active_pool = _load_auth_store().get("credential_pool")
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
changed |= bool(active_entries)
if provider.startswith(CUSTOM_POOL_PREFIX):
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
changed |= custom_changed
changed |= _prune_stale_seeded_entries(entries, custom_sources)
else:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed |= singleton_changed or env_changed
# ``load_pool()`` is a non-destructive read for env-seeded entries
# (#9331); file-backed singletons still prune when their file is gone.
if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS and disk_ids:
owns_provider = _profile_owns_pool_provider(provider)
if owns_provider is False:
# Rows read through the global-root fallback are seeded from the
# ROOT's singleton files, which this profile cannot see; pruning
# them would hide (and, via write-through, delete) the shared
# grant. The root's own load_pool() prunes.
borrowed = [e for e in entries if e.id in disk_ids]
others = [e for e in entries if e.id not in disk_ids]
changed |= _prune_stale_seeded_entries(
others, singleton_sources | env_sources, prune_env_sources=False,
)
entries[:] = borrowed + others
else:
changed |= _prune_stale_seeded_entries(
entries, singleton_sources | env_sources, prune_env_sources=False,
)
changed |= _normalize_pool_priorities(provider, entries)
pool = CredentialPool(provider, entries)
pool._persisted_token_pairs = auth_mod._token_pairs_by_id(raw_entries)
if changed:
pool._persist(removed_ids=sorted(disk_ids - {entry.id for entry in entries}))
# Remember the root's borrowed rows so a later ``add_entry`` in this
# profile leaves them out of the profile's own store (#100339).
# No disk rows -> nothing borrowed; the ``set()`` default already applies.
if provider in SINGLE_USE_REFRESH_POOL_PROVIDERS and disk_ids:
# Reuse the pre-persist ownership answer unless _persist() just rewrote
# the store (it can give the profile its own rows); nothing else between
# the two checks touches auth.json.
if changed:
owns_provider = _profile_owns_pool_provider(provider)
if not owns_provider:
pool._borrowed_root_ids = set(disk_ids)
return pool