"""Multi-provider authentication system for Hermes Agent. Architecture: - ``ProviderConfig`` / ``PROVIDER_REGISTRY`` describe every known inference provider. - The auth store (``~/.hermes/auth.json``) holds per-provider credential state, the credential pool and suppression markers; ``_auth_store_lock`` / ``_load_auth_store`` / ``_save_auth_store`` are the only I/O primitives (cross-process flock, atomic 0o600 writes). - ``resolve_provider()`` picks the active provider via the documented priority chain. - ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its runtime-credential resolver, status builder and terminal-refresh error codes; the flows themselves live in sibling modules (``auth_nous``, ``auth_codex``, ``auth_xai``, ``auth_qwen``, ``auth_minimax``, ``auth_spotify``) and are re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface. - ``get_auth_status()`` / ``resolve_api_key_provider_credentials()`` / ``logout_command()`` are the generic entry points the CLI, gateway and dashboard call. """ from __future__ import annotations import json import logging import os import shutil import shlex import stat import threading import time import uuid import webbrowser # noqa: F401 (tests patch auth_mod.webbrowser.open; same module object) from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple from urllib.parse import urlparse from hermes_cli.config import ( get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write, ) from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir from agent.credential_persistence import sanitize_borrowed_credential_payload from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value # noqa: F401 (env_float: agent.credential_pool reads auth_mod.env_float) from hermes_cli.auth_zai_kimi import ( # noqa: F401 re-exported KIMI_CODE_BASE_URL, ZAI_ENDPOINTS, _normalize_lmstudio_runtime_base_url, _resolve_kimi_base_url, _resolve_zai_base_url, detect_zai_endpoint, ) from hermes_cli.auth_model_picker import ( # noqa: F401 re-exported _prompt_model_selection, _save_model_choice, ) from hermes_cli.auth_device_flow import ( # noqa: F401 re-exported _can_open_graphical_browser, _default_verify, _is_remote_session, _nous_device_auth_timeout_message, _offer_existing_oauth_credentials, _poll_device_token_generic, _poll_for_token, _print_device_code_instructions, _print_login_success, _print_loopback_ssh_hint, _prompt_yes_no, _request_device_code, _resolve_verify, _ssh_user_at_host, ) from hermes_cli.auth_oauth_grants import ( # noqa: F401 re-exported SINGLE_USE_REFRESH_POOL_PROVIDERS, _oauth_heal_clean_marks, _oauth_heal_notices, consume_oauth_heal_notices, heal_forked_single_use_oauth_grants, strip_cloned_single_use_oauth_grants, ) from hermes_cli.auth_nous import ( # noqa: F401 re-exported NOUS_SESSION_TERMINAL, NOUS_SESSION_UNKNOWN, NOUS_SESSION_VALID, _ALLOWED_NOUS_INFERENCE_HOSTS, _agent_key_is_usable, _apply_nous_refreshed_tokens, _assert_nous_inference_jwt_usable, _compute_nous_auth_status, _format_nous_entitlement_auth_error, _healed_nous_inference_url, _login_nous, _merge_shared_nous_oauth_state, _migrate_stale_nous_portal_url, _nous_device_code_login, _nous_inference_env_override, _nous_invoke_jwt_is_usable, _nous_invoke_jwt_status, _nous_portal_env_override, _nous_shared_store_lock, _nous_shared_store_path, _pool_first_oauth_status, _quarantine_nous_oauth_state, _quarantine_nous_pool_entries, _read_shared_nous_state, _refresh_access_token, _refresh_nous_or_quarantine, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store, _token_fingerprint, _try_import_shared_nous_state, _validate_nous_inference_url_from_network, _write_shared_nous_state, fetch_nous_models, get_nous_auth_status_local, get_nous_session_validity, persist_nous_credentials, refresh_nous_oauth_from_state, resolve_nous_runtime_credentials, step_up_nous_billing_scope, ) from hermes_cli.auth_minimax import ( # noqa: F401 re-exported _MINIMAX_OAUTH_ERROR_BODY_LIMIT, _login_minimax_oauth, _minimax_oauth_login, _minimax_pkce_pair, _minimax_poll_token, _minimax_post_form, _minimax_request_user_code, _minimax_resolve_token_expiry_unix, _minimax_response_error_text, _minimax_save_auth_state, _refresh_minimax_oauth_state, build_minimax_oauth_token_provider, resolve_minimax_oauth_runtime_credentials, ) from hermes_cli.auth_xai import ( # noqa: F401 re-exported _login_xai_oauth, _read_xai_oauth_tokens, _refresh_xai_oauth_tokens, _save_xai_oauth_tokens, _write_through_xai_oauth_to_global_root, _xai_access_token_is_expiring, _xai_oauth_device_code_login, _xai_oauth_discovery, _xai_oauth_poll_device_token, _xai_oauth_request_device_code, _xai_proactive_refresh_skew_seconds, _xai_validate_inference_base_url, refresh_xai_oauth_pure, resolve_xai_oauth_runtime_credentials, ) from hermes_cli.auth_codex import ( # noqa: F401 re-exported _codex_access_token_is_expiring, _codex_device_code_login, _codex_http_client, _codex_pool_rate_limit_status, _codex_quota_probe_cache, _codex_usage_probe_url, _import_codex_cli_tokens, _is_codex_rate_limit_shaped, _login_openai_codex, _probe_codex_quota_restored, _read_codex_tokens, _refresh_codex_auth_tokens, _save_codex_tokens, clear_codex_pool_quota_cooldowns, refresh_codex_oauth_pure, resolve_codex_runtime_credentials, ) from hermes_cli.auth_spotify import ( # noqa: F401 re-exported _refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command, resolve_spotify_runtime_credentials, ) from hermes_cli.auth_qwen import ( # noqa: F401 re-exported _qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens, _refresh_qwen_cli_tokens, _save_qwen_cli_tokens, get_qwen_auth_status, resolve_qwen_runtime_credentials, ) from hermes_cli.auth_constants import ( # noqa: F401 re-exported _decode_jwt_claims, AUTH_STORE_VERSION, AUTH_LOCK_TIMEOUT_SECONDS, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_CLIENT_ID, NOUS_BILLING_MANAGE_SCOPE, DEFAULT_NOUS_SCOPE, NOUS_DEVICE_CODE_SOURCE, NOUS_AUTH_PATH_INVOKE_JWT, ACCESS_TOKEN_REFRESH_SKEW_SECONDS, NOUS_INVOKE_JWT_MIN_TTL_SECONDS, DEFAULT_CODEX_BASE_URL, DEFAULT_XAI_OAUTH_BASE_URL, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_SCOPE, MINIMAX_OAUTH_GLOBAL_BASE, MINIMAX_OAUTH_CN_BASE, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_CN_INFERENCE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, DEFAULT_QWEN_BASE_URL, DEFAULT_GITHUB_MODELS_BASE_URL, DEFAULT_COPILOT_ACP_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL, DEFAULT_ACTUAL_BASE_URL, DEFAULT_ACTUAL_LOCAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL, STEPFUN_STEP_PLAN_CN_BASE_URL, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE, XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, SPOTIFY_DOCS_URL, DEFAULT_SPOTIFY_SCOPE, SERVICE_PROVIDER_NAMES, LMSTUDIO_NOAUTH_PLACEHOLDER, ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, CODEX_RATE_LIMITED_CODE, AuthError, _nous_err, httpx, ) logger = logging.getLogger(__name__) try: import fcntl except Exception: fcntl = None try: import msvcrt except Exception: msvcrt = None def is_actual_local_base_url(base_url: str) -> bool: """Return True for Actual's loopback local API endpoint.""" try: host = (urlparse(base_url or "").hostname or "").lower().rstrip(".") except Exception: return False return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} def normalize_actual_base_url(base_url: str) -> str: """Return Actual's OpenAI-compatible base URL. Hosted inference lives at api.actual.inc; the Actual client's offline local server binds a loopback host. Both expose a /v1 surface for the Responses transport. """ url = str(base_url or "").strip().rstrip("/") if not url: return DEFAULT_ACTUAL_BASE_URL try: parsed = urlparse(url) host = (parsed.hostname or "").lower().rstrip(".") path = parsed.path.rstrip("/") except Exception: return url if host == "api.actual.inc" and path in {"", "/"}: return url + "/v1" if is_actual_local_base_url(url) and path in {"", "/"}: return url + "/v1" return url # ============================================================================= # Provider Registry # ============================================================================= @dataclass class ProviderConfig: """Describes a known inference provider.""" id: str name: str auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key" portal_base_url: str = "" inference_base_url: str = "" client_id: str = "" scope: str = "" extra: Dict[str, Any] = field(default_factory=dict) # For API-key providers: env vars to check (in priority order) api_key_env_vars: tuple = () # Optional env var for base URL override base_url_env_var: str = "" def _api_key_provider( id: str, name: str, inference_base_url: str, api_key_env_vars: tuple, base_url_env_var: str = "", *, auth_type: str = "api_key", ) -> ProviderConfig: """Compact constructor for the common env-var-keyed provider shape.""" return ProviderConfig( id=id, name=name, auth_type=auth_type, inference_base_url=inference_base_url, api_key_env_vars=api_key_env_vars, base_url_env_var=base_url_env_var, ) PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { "nous": ProviderConfig( id="nous", name="Nous Portal", auth_type="oauth_device_code", portal_base_url=DEFAULT_NOUS_PORTAL_URL, inference_base_url=DEFAULT_NOUS_INFERENCE_URL, client_id=DEFAULT_NOUS_CLIENT_ID, scope=DEFAULT_NOUS_SCOPE, ), "openai-codex": ProviderConfig( id="openai-codex", name="OpenAI Codex", auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), "openai-api": _api_key_provider( "openai-api", "OpenAI API", "https://api.openai.com/v1", ("OPENAI_API_KEY",), "OPENAI_BASE_URL", ), "xai-oauth": ProviderConfig( id="xai-oauth", name="xAI Grok OAuth (SuperGrok / Premium+)", auth_type="oauth_external", inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL, ), "qwen-oauth": ProviderConfig( id="qwen-oauth", name="Qwen OAuth", auth_type="oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL, ), "lmstudio": _api_key_provider( "lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", ("LM_API_KEY",), "LM_BASE_URL", ), "copilot": _api_key_provider( "copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL, ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL", ), "copilot-acp": ProviderConfig( id="copilot-acp", name="GitHub Copilot ACP", auth_type="external_process", inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL", ), "gemini": _api_key_provider( "gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta", ("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL", ), "zai": _api_key_provider( "zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "GLM_BASE_URL", ), # Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat). # sk-kimi- (Kimi Code) keys are auto-redirected to api.kimi.com/coding # by _resolve_kimi_base_url() below. "kimi-coding": _api_key_provider( "kimi-coding", "Kimi / Moonshot", "https://api.moonshot.ai/v1", ("KIMI_API_KEY", "KIMI_CODING_API_KEY"), "KIMI_BASE_URL", ), "kimi-coding-cn": _api_key_provider( "kimi-coding-cn", "Kimi / Moonshot (China)", "https://api.moonshot.cn/v1", ("KIMI_CN_API_KEY",), ), "stepfun": _api_key_provider( "stepfun", "StepFun Step Plan", STEPFUN_STEP_PLAN_INTL_BASE_URL, ("STEPFUN_API_KEY",), "STEPFUN_BASE_URL", ), "arcee": _api_key_provider( "arcee", "Arcee AI", "https://api.arcee.ai/api/v1", ("ARCEEAI_API_KEY",), "ARCEE_BASE_URL", ), "gmi": _api_key_provider( "gmi", "GMI Cloud", "https://api.gmi-serving.com/v1", ("GMI_API_KEY",), "GMI_BASE_URL", ), "actual": _api_key_provider( "actual", "Actual Computer", DEFAULT_ACTUAL_BASE_URL, ("ACTUAL_API_KEY",), "ACTUAL_BASE_URL", ), "minimax": _api_key_provider( "minimax", "MiniMax", "https://api.minimax.io/anthropic", ("MINIMAX_API_KEY",), "MINIMAX_BASE_URL", ), "minimax-oauth": ProviderConfig( id="minimax-oauth", name="MiniMax (OAuth \u00b7 minimax.io)", auth_type="oauth_minimax", portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE, client_id=MINIMAX_OAUTH_CLIENT_ID, scope=MINIMAX_OAUTH_SCOPE, extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE, "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}, ), # CLAUDE_CODE_OAUTH_TOKEN is NOT an API key, despite auth_type="api_key" # and its place in this tuple (#82154). `claude setup-token` yields an # `sk-ant-oat01…` OAuth token: sent as `x-api-key` it 401s, and sent as a # bare Bearer it 429s. It is listed here because this tuple doubles as the # credential-DISCOVERY list (agent/credential_pool.py builds its env scan # from it), so removing it would stop Hermes finding a setup-token # credential at all. The adapter routes such a value down the OAuth path # on the strength of its prefix, not on this entry. Only ANTHROPIC_API_KEY # and ANTHROPIC_TOKEN are usable as literal API keys. "anthropic": _api_key_provider( "anthropic", "Anthropic", "https://api.anthropic.com", ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), "ANTHROPIC_BASE_URL", ), "alibaba": _api_key_provider( "alibaba", "Qwen Cloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", ("DASHSCOPE_API_KEY",), "DASHSCOPE_BASE_URL", ), "alibaba-coding-plan": _api_key_provider( "alibaba-coding-plan", "Alibaba Cloud (Coding Plan)", "https://coding-intl.dashscope.aliyuncs.com/v1", ("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"), "ALIBABA_CODING_PLAN_BASE_URL", ), "minimax-cn": _api_key_provider( "minimax-cn", "MiniMax (China)", "https://api.minimaxi.com/anthropic", ("MINIMAX_CN_API_KEY",), "MINIMAX_CN_BASE_URL", ), "deepseek": _api_key_provider( "deepseek", "DeepSeek", "https://api.deepseek.com/v1", ("DEEPSEEK_API_KEY",), "DEEPSEEK_BASE_URL", ), "xai": _api_key_provider("xai", "xAI", "https://api.x.ai/v1", ("XAI_API_KEY",), "XAI_BASE_URL"), "nvidia": _api_key_provider( "nvidia", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", ("NVIDIA_API_KEY",), "NVIDIA_BASE_URL", ), "ai-gateway": _api_key_provider( "ai-gateway", "Vercel AI Gateway", "https://ai-gateway.vercel.sh/v1", ("AI_GATEWAY_API_KEY",), "AI_GATEWAY_BASE_URL", ), "opencode-zen": _api_key_provider( "opencode-zen", "OpenCode Zen", "https://opencode.ai/zen/v1", ("OPENCODE_ZEN_API_KEY",), "OPENCODE_ZEN_BASE_URL", ), # OpenCode Go mixes API surfaces by model: # - GLM / Kimi use OpenAI-compatible chat completions under /v1 # - MiniMax models use Anthropic Messages under /v1/messages # - Qwen 3.7 uses Anthropic Messages under /v1/messages # Keep the provider base at /v1 and select api_mode per-model. "opencode-go": _api_key_provider( "opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", ("OPENCODE_GO_API_KEY",), "OPENCODE_GO_BASE_URL", ), # Deliberately NO api_key_env_vars: the free tier is served # anonymously (any unrecognized bearer is a 401), so there is no # secret to configure. Select via `hermes model` / `/model free`. "opencode-free": _api_key_provider("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()), "kilocode": _api_key_provider( "kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL", ), "huggingface": _api_key_provider( "huggingface", "Hugging Face", "https://router.huggingface.co/v1", ("HF_TOKEN",), "HF_BASE_URL", ), "xiaomi": _api_key_provider( "xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL", ), "tencent-tokenhub": _api_key_provider( "tencent-tokenhub", "Tencent TokenHub", "https://tokenhub.tencentmaas.com/v1", ("TOKENHUB_API_KEY",), "TOKENHUB_BASE_URL", ), "tencent-tokenplan": _api_key_provider( "tencent-tokenplan", "Tencent TokenPlan", "https://api.lkeap.cloud.tencent.com/plan/anthropic", ("TOKENPLAN_API_KEY",), "TOKENPLAN_BASE_URL", ), "ollama-cloud": _api_key_provider( "ollama-cloud", "Ollama Cloud", DEFAULT_OLLAMA_CLOUD_BASE_URL, ("OLLAMA_API_KEY",), "OLLAMA_BASE_URL", ), "bedrock": _api_key_provider( "bedrock", "AWS Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", (), "BEDROCK_BASE_URL", auth_type="aws_sdk", ), # No static inference_base_url: Vertex's endpoint is computed per # request from project_id + region (agent/vertex_adapter.py's # build_vertex_base_url), not a fixed host like the other entries. "vertex": _api_key_provider("vertex", "Google Vertex AI", "", (), auth_type="vertex"), "azure-foundry": _api_key_provider( "azure-foundry", "Azure Foundry", "", ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL", ), } # Providers handled outside the registry: copilot/kimi/zai have bespoke token refresh here; # openrouter/custom are aggregator/user-supplied and runtime_provider relies on # ``openrouter not in PROVIDER_REGISTRY``. _REGISTRY_PLUGIN_SKIP = frozenset({"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}) def _register_plugin_provider(pp: Any) -> None: """Auto-register one providers/ profile (plugins/model-providers//) not declared above. External-process providers (an ACP CLI over stdio) have no API-key env vars; registering them is what lets a provider shipped outside this tree pass ``resolve_provider()``'s known-provider gate (otherwise ``hermes -m `` dies with "Unknown provider" before a client is built). """ if pp.auth_type == "external_process": pconfig = ProviderConfig( id=pp.name, name=pp.display_name or pp.name, auth_type="external_process", inference_base_url=pp.base_url, ) elif pp.auth_type == "api_key" and pp.env_vars and pp.name not in _REGISTRY_PLUGIN_SKIP: is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731 pconfig = ProviderConfig( id=pp.name, name=pp.display_name or pp.name, auth_type="api_key", inference_base_url=pp.base_url, api_key_env_vars=tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars, base_url_env_var=next((v for v in pp.env_vars if is_url(v)), None) or "", ) else: return PROVIDER_REGISTRY[pp.name] = pconfig for alias in pp.aliases: # so resolve_provider() resolves them too PROVIDER_REGISTRY.setdefault(alias, pconfig) try: from providers import list_providers as _list_providers_for_registry for _pp in _list_providers_for_registry(): if _pp.name not in PROVIDER_REGISTRY: _register_plugin_provider(_pp) except Exception: pass # ============================================================================= # Anthropic Key Helper # ============================================================================= def get_anthropic_key() -> str: """Return the first usable Anthropic credential, or ``""``. Checks both the ``.env`` file and the process environment, preferring ``~/.hermes/.env`` so a deliberate key rotation isn't shadowed by a stale shell export (matches the api-key resolution path — see #20591). The order mirrors the ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars`` tuple: """ from hermes_cli.config import get_env_value_prefer_dotenv for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars: value = get_env_value_prefer_dotenv(var) or "" if value: return value return "" # ============================================================================= # Secret validation # ============================================================================= _PLACEHOLDER_SECRET_VALUES = { "*", "**", "***", "changeme", "your_api_key", "your_api_key_here", "your-api-key", "placeholder", "example", "dummy", "null", "none", } def has_usable_secret(value: Any, *, min_length: int = 4) -> bool: """Return True when a configured secret looks usable, not empty/placeholder.""" if not isinstance(value, str): return False cleaned = value.strip() if len(cleaned) < min_length: return False return cleaned.lower() not in _PLACEHOLDER_SECRET_VALUES # Known API-key prefixes per provider. Only providers listed here get # prefix validation; everyone else is fail-open (unknown formats pass). # This exists so an obviously malformed key in .env (truncated paste, wrong # provider's key in the wrong var, etc.) doesn't silently shadow a valid # credential-pool entry and produce opaque 401s (#93593). KNOWN_PROVIDER_KEY_PREFIXES: Dict[str, tuple] = { # All OpenRouter keys are issued as sk-or-... (currently sk-or-v1-). "openrouter": ("sk-or-",), } def _secret_matches_declared_prefix(provider_id: str, value: str) -> bool: """Return False only when the provider declares key prefixes and none match. Providers without a declared prefix always pass (fail-open): we never hard-reject unknown key formats, only skip values that provably don't belong to a provider whose key format we know. """ prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id) if not prefixes: return True return any(value.startswith(p) for p in prefixes) def _warn_malformed_secret(provider_id: str, source: str) -> None: prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id, ()) logger.warning( "Ignoring %s for provider %r: value does not match the expected key " "prefix (%s). Falling back to the next credential source. Fix or " "remove the malformed key to silence this warning.", source, provider_id, " or ".join(prefixes), ) def _resolve_api_key_provider_secret( provider_id: str, pconfig: ProviderConfig ) -> tuple[str, str]: """Resolve an API-key provider's token and indicate where it came from.""" if provider_id == "copilot": # Use the dedicated copilot auth module for proper token validation try: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() if token: api_token, _base_url = get_copilot_api_token(token) return api_token, source except ValueError as exc: logger.warning("Copilot token validation failed: %s", exc) except Exception: pass return "", "" from hermes_cli.config import get_env_value_prefer_dotenv for env_var in pconfig.api_key_env_vars: # Prefer ~/.hermes/.env over os.environ so a deliberate key rotation # in the user's .env file isn't shadowed by a stale shell export # inherited from a parent process (Codex CLI, test runners, etc.). val = (get_env_value_prefer_dotenv(env_var) or "").strip() if not has_usable_secret(val): continue if not _secret_matches_declared_prefix(provider_id, val): # A provably malformed key (declared prefix mismatch) must not # shadow a valid credential-pool entry (#93593). Warn and keep # looking instead of returning it. _warn_malformed_secret(provider_id, env_var) continue return val, env_var # Fallback: try credential pool (e.g. zai key stored via auth.json) try: from agent.credential_pool import load_pool pool = load_pool(provider_id) if pool and pool.has_credentials(): # Prefer the pool's own selection (peek), but iterate the rest of # the entries too so one malformed entry doesn't block a valid one. candidates = [] entry = pool.peek() if entry is not None: candidates.append(entry) try: for extra in pool.entries(): if extra is not None and all(extra is not c for c in candidates): candidates.append(extra) except Exception: pass for entry in candidates: key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "") key = str(key).strip() if not has_usable_secret(key): continue if not _secret_matches_declared_prefix(provider_id, key): _warn_malformed_secret(provider_id, f"credential_pool:{provider_id}") continue return key, f"credential_pool:{provider_id}" except Exception: pass return "", "" # ============================================================================= # Error formatting (AuthError itself lives in auth_constants) # ============================================================================= def is_rate_limited_auth_error(error: Exception) -> bool: """True when an :class:`AuthError` represents upstream rate-limiting / quota These failures are transient and re-authenticating cannot fix them, so callers should show a "retry later" notice and prefer a fallback chain instead of suggesting ``hermes auth``. """ return ( isinstance(error, AuthError) and not error.relogin_required and error.code == CODEX_RATE_LIMITED_CODE ) def format_auth_error(error: Exception) -> str: """Map auth failures to concise user-facing guidance.""" if not isinstance(error, AuthError): return str(error) # Rate-limit / quota errors are not credential problems — never append the # "re-authenticate" remediation, which would mislead the operator. if is_rate_limited_auth_error(error): return str(error) if error.relogin_required: return f"{error} Run `hermes model` to re-authenticate." if error.code in _ENTITLEMENT_ERROR_CODES: if error.provider == "nous": return _format_nous_entitlement_auth_error(error) generic = _GENERIC_ENTITLEMENT_MESSAGES.get(error.code) if generic: return generic if error.code == "temporarily_unavailable": return f"{error} Please retry in a few seconds." return str(error) # Entitlement failures: Nous gets a Portal-aware message; other providers a fixed # generic one (or the raw error when no generic text exists for the code). _GENERIC_ENTITLEMENT_MESSAGES = { "subscription_required": "No active paid subscription found. Please purchase/activate a subscription, then retry.", "insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry.", } _ENTITLEMENT_ERROR_CODES = frozenset(_GENERIC_ENTITLEMENT_MESSAGES) | { "subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded", } def _nonempty_str(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) # ============================================================================= # Auth Store — persistence layer for ~/.hermes/auth.json # ============================================================================= def _auth_file_path() -> Path: path = get_hermes_home() / "auth.json" # Seat belt: if pytest is running and HERMES_HOME resolves to the real # user's auth store, refuse rather than silently corrupt it. This catches # tests that forgot to monkeypatch HERMES_HOME, tests invoked without the # hermetic conftest, or sandbox escapes via threads/subprocesses. In # production (no PYTEST_CURRENT_TEST) this is a single dict lookup. if os.environ.get("PYTEST_CURRENT_TEST"): real_home_auth = (Path.home() / ".hermes" / "auth.json").resolve(strict=False) try: resolved = path.resolve(strict=False) except Exception: resolved = path if resolved == real_home_auth: raise RuntimeError( f"Refusing to touch real user auth store during test run: {path}. " "Set HERMES_HOME to a tmp_path in your test fixture, or run " "via scripts/run_tests.sh for hermetic CI-parity env." ) return path def _global_auth_file_path() -> Optional[Path]: """Return the global-root auth.json when the process is in profile mode. Returns ``None`` when the profile and global root resolve to the same directory (classic mode, or custom HERMES_HOME that is not a profile). Used by read-only fallback paths so providers authed at the root are visible to profile processes that haven't configured them locally. """ try: from hermes_constants import get_default_hermes_root global_root = get_default_hermes_root() except Exception: return None profile_home = get_hermes_home() try: if profile_home.resolve(strict=False) == global_root.resolve(strict=False): return None except Exception: if profile_home == global_root: return None # No pytest seat belt here: this is a pure read-only path, and # ``_load_global_auth_store()`` wraps the read in a try/except so an # unreadable global file can never break the profile process. The # write-side seat belt still lives on ``_auth_file_path()`` where it # belongs (that's what protects the real user's auth store from being # corrupted by a mis-configured test). return global_root / "auth.json" def _load_global_auth_store() -> Dict[str, Any]: """Load the global-root auth store (read-only fallback). Returns an empty dict when no global fallback exists (classic mode, or the global auth.json is absent). Never raises on missing file. """ global _global_auth_store_cache global_path = _global_auth_file_path() if global_path is None or not global_path.exists(): _global_auth_store_cache = None return {} try: resolved_path = str(global_path.resolve(strict=False)) mtime_ns = global_path.stat().st_mtime_ns cache_key: Optional[Tuple[str, int]] = (resolved_path, mtime_ns) except Exception: cache_key = None if cache_key is not None and _global_auth_store_cache is not None: cached_path, cached_mtime, cached_store = _global_auth_store_cache if cached_path == cache_key[0] and cached_mtime == cache_key[1]: return cached_store 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: if global_path.resolve(strict=False) == real_root.resolve(strict=False): _global_auth_store_cache = None return {} except Exception: pass try: store = _load_auth_store(global_path) except Exception: # A malformed global store must not break profile reads. The # profile's own auth store is still authoritative. _global_auth_store_cache = None return {} if cache_key is not None: _global_auth_store_cache = (cache_key[0], cache_key[1], store) return store def _auth_lock_path() -> Path: return _auth_file_path().with_suffix(".lock") _auth_target_lock_holders: Dict[str, threading.local] = {} _auth_target_lock_holders_guard = threading.Lock() def _same_path(left: Path, right: Path) -> bool: try: return left.resolve(strict=False) == right.resolve(strict=False) except Exception: return left == right def _auth_lock_holder_for(target_path: Path) -> threading.local: """Return a reentrancy tracker keyed to one canonical auth-store path.""" try: key = str(target_path.resolve(strict=False)) except Exception: key = str(target_path) with _auth_target_lock_holders_guard: return _auth_target_lock_holders.setdefault(key, threading.local()) @contextmanager def _file_lock( lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str, ): """Cross-process advisory flock helper. Reentrant per-thread via ``holder.depth``. Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available (rare). Callers supply their own ``threading.local`` so independent locks (e.g. the profile auth store vs the global root store vs the shared Nous store) track reentrancy separately. """ if getattr(holder, "depth", 0) > 0: holder.depth += 1 try: yield finally: holder.depth -= 1 return lock_path.parent.mkdir(parents=True, exist_ok=True) if fcntl is None and msvcrt is None: holder.depth = 1 try: yield finally: holder.depth = 0 return # On Windows, msvcrt.locking needs the file to have content and the # file pointer at position 0. Ensure the lock file has at least 1 byte. # Under real concurrency (many threads/processes racing this same # ensure-content check) this write can collide with another holder's # msvcrt byte-range lock on the same file and raise PermissionError -- # uncaught, since it happens before the retry loop below even starts. # A stress test with 20 concurrent Hermes processes reproduced this # deterministically on Windows. It's a best-effort convenience write # (whoever gets there first wins); losing the race here just means the # lock file already has content, so swallow the failure and proceed # straight to the acquire-with-retry loop. if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): try: lock_path.write_text(" ", encoding="utf-8") except (OSError, PermissionError): pass with lock_path.open("r+" if msvcrt else "a+", encoding="utf-8") as lock_file: deadline = time.monotonic() + max(1.0, timeout_seconds) while True: try: if fcntl: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) else: lock_file.seek(0) msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) break except (BlockingIOError, OSError, PermissionError): if time.monotonic() >= deadline: raise TimeoutError(timeout_message) time.sleep(0.05) holder.depth = 1 try: yield finally: holder.depth = 0 if fcntl: try: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) except (OSError, IOError): pass elif msvcrt: try: lock_file.seek(0) msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) except (OSError, IOError): pass @contextmanager def _auth_store_lock( timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None, ): """Cross-process advisory lock for one auth.json read/write transaction. ``target_path`` is required for profile-to-global write-throughs. A profile lock does not protect the distinct global auth store; each path therefore uses its own reentrancy tracker and kernel lock. Lock ordering invariant: when this lock is held together with ``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST (outer) and the shared Nous lock SECOND (inner). All runtime refresh paths follow this order; violating it risks deadlock against a concurrent import on the shared store. """ auth_path = target_path if target_path is not None else _auth_file_path() lock_path = auth_path.with_suffix(".lock") if target_path is not None else _auth_lock_path() with _file_lock( lock_path, _auth_lock_holder_for(auth_path), timeout_seconds, "Timed out waiting for auth store lock", ): yield def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: auth_file = auth_file or _auth_file_path() if not auth_file.exists(): return {"version": AUTH_STORE_VERSION, "providers": {}} try: raw = json.loads(auth_file.read_text(encoding="utf-8-sig")) except OSError: # The file exists (checked above) but could not be READ: EMFILE under # fd exhaustion, EACCES, EIO, a stalled network mount. None of those # mean the contents are bad, and this module does read-modify-write in # ~15 places, so degrading to an empty store here is one # _save_auth_store() away from erasing every stored credential. # Fail loudly instead and leave the file on disk untouched. logger.warning( "auth: could not read %s, leaving the store on disk untouched " "rather than degrading to an empty one", auth_file, exc_info=True, ) raise except Exception as exc: # Genuine corruption: unparseable JSON, or bytes that are not UTF-8. corrupt_path = auth_file.with_suffix(".json.corrupt") try: shutil.copy2(auth_file, corrupt_path) preserved = True except Exception: preserved = False logger.debug( "auth: could not preserve a copy of the corrupt store at %s", corrupt_path, exc_info=True, ) # Never advertise a backup that was not written. logger.warning( "auth: failed to parse %s (%s), starting with empty store. %s %s", auth_file, exc, "Corrupt file preserved at" if preserved else "A copy could NOT be preserved at", corrupt_path, ) return {"version": AUTH_STORE_VERSION, "providers": {}} if isinstance(raw, dict) and ( isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict) ): raw.setdefault("providers", {}) if isinstance(raw.get("providers"), dict): _migrate_stale_nous_portal_url(raw["providers"]) return raw # Migrate from PR's "systems" format if present if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): systems = raw["systems"] providers = {} if "nous_portal" in systems: providers["nous"] = systems["nous_portal"] return {"version": AUTH_STORE_VERSION, "providers": providers, "active_provider": "nous" if providers else None} return {"version": AUTH_STORE_VERSION, "providers": {}} def _write_private_file_atomic( target: Path, payload: str, *, replace: Optional[Callable[[Any, Any], Any]] = None, fsync_dir: bool = False, ) -> None: """Write *payload* to *target* via a 0o600 temp file + atomic rename. Creating the temp with ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write ``chmod`` briefly exposed tokens at process umask (often 0o644). Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148). The per-process random temp suffix avoids collisions between concurrent writers and stale leftovers from a crashed prior write. """ target.parent.mkdir(parents=True, exist_ok=True) # secure_parent_dir refuses to chmod /, top-level dirs, or the # hermes-agent install tree (#25821, #93050). secure_parent_dir(target) tmp_path = target.with_name(f"{target.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: fd = os.open( str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR, ) with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(payload) handle.flush() os.fsync(handle.fileno()) (replace or atomic_replace)(tmp_path, target) if fsync_dir: try: dir_fd = os.open(str(target.parent), os.O_RDONLY) except OSError: dir_fd = None if dir_fd is not None: try: os.fsync(dir_fd) finally: os.close(dir_fd) finally: try: if tmp_path.exists(): tmp_path.unlink() except OSError: pass def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path: # target_path=None preserves the existing contract (write the active # store at _auth_file_path()). An explicit path lets callers persist a # specific store — e.g. the global-root write-through for rotating xAI # OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600 # write so the root auth.json gets the same TOCTOU-safe treatment. auth_file = target_path if target_path is not None else _auth_file_path() auth_store["version"] = AUTH_STORE_VERSION auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() # Parent dir is tightened to 0o700 inside the writer so siblings can't # traverse to creds (no-op on Windows; failures ignored). _write_private_file_atomic(auth_file, json.dumps(auth_store, indent=2) + "\n", fsync_dir=True) # Restrict file permissions to owner only try: auth_file.chmod(stat.S_IRUSR | stat.S_IWUSR) except OSError: pass return auth_file def _store_section(auth_store: Dict[str, Any], key: str) -> Dict[str, Any]: """Return ``auth_store[key]`` as a dict, replacing a missing/non-dict value in place.""" section = auth_store.get(key) if not isinstance(section, dict): section = {} auth_store[key] = section return section def _load_provider_state_with_source( auth_store: Dict[str, Any], provider_id: str, ) -> tuple[Optional[Dict[str, Any]], Optional[Path]]: """Return a provider state plus the auth.json path it came from. Most callers only need the state, but refresh paths that rotate single-use OAuth refresh tokens must write the updated token chain back to the same store they read. """ state = _provider_state_in(auth_store, provider_id) if state is not None: return state, _auth_file_path() global_state = _provider_state_in(_load_global_auth_store(), provider_id) if global_state is not None: return global_state, _global_auth_file_path() return None, None def _provider_state_in(store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: """Shallow copy of ``store["providers"][provider_id]`` when it is a dict, else None.""" providers = store.get("providers") if store else None if isinstance(providers, dict): state = providers.get(provider_id) if isinstance(state, dict): return dict(state) return None @contextmanager def _provider_state_transaction(provider_id: str): """Lock the active auth store and any global fallback source in order. Profile-backed refresh paths must take the global auth-store lock before any provider-specific shared-store lock. Re-reading the source after the target lock is acquired prevents both stale refreshes and whole-file lost updates without inverting the documented auth -> shared lock order. """ with _auth_store_lock(): auth_store = _load_auth_store() state, source_path = _load_provider_state_with_source( auth_store, provider_id, ) active_path = _auth_file_path() if source_path is None or _same_path(source_path, active_path): yield auth_store, state, source_path return with _auth_store_lock(target_path=source_path): source_state = _provider_state_in(_load_auth_store(source_path), provider_id) yield auth_store, source_state, source_path def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: """Return a provider's persisted state. In profile mode, falls back to the global-root ``auth.json`` when the profile has no entry for ``provider_id``. This mirrors the per-provider shadowing already used by ``read_credential_pool``: workers spawned in a profile can see providers (e.g. ``nous``) that were only authenticated at global scope. """ state, _source_path = _load_provider_state_with_source(auth_store, provider_id) return state def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None: """Write *state* under ``providers`` and make *provider_id* the active provider.""" _store_provider_state(auth_store, provider_id, state, set_active=True) def _save_active_provider_state(provider_id: str, state: Dict[str, Any]) -> Path: """Lock, load, write *state* as the active provider, save. Returns the auth store path.""" with _auth_store_lock(): auth_store = _load_auth_store() _save_provider_state(auth_store, provider_id, state) return _save_auth_store(auth_store) def _save_provider_state_to_source( auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], source_path: Optional[Path], ) -> None: """Persist provider state back to the auth store it was read from.""" active_path = _auth_file_path() if source_path is None or _same_path(source_path, active_path): _save_provider_state(auth_store, provider_id, state) _save_auth_store(auth_store) return _persist_provider_state_to_store( provider_id, state, source_path, set_active=True, ) def _store_provider_state( auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], *, set_active: bool = True, ) -> None: _store_section(auth_store, "providers")[provider_id] = state if set_active: auth_store["active_provider"] = provider_id def _persist_provider_state_to_store( provider_id: str, state: Dict[str, Any], target_path: Path, *, set_active: bool = False, ) -> Path: """Merge one provider into a specific auth store under that store's lock.""" with _auth_store_lock(target_path=target_path): auth_store = _load_auth_store(target_path) _store_provider_state( auth_store, provider_id, dict(state), set_active=set_active, ) return _save_auth_store(auth_store, target_path=target_path) def mark_provider_active_if_unset(provider_id: str) -> None: """Set ``active_provider`` to *provider_id* only when none is set yet. Used by ``hermes auth add`` OAuth paths that write pool entries directly: the first credential for a provider must make it active so the setup wizard's credential check does not report "No inference provider configured". Later adds leave the user's chosen provider untouched. """ with _auth_store_lock(): auth_store = _load_auth_store() if not (auth_store.get("active_provider") or "").strip(): auth_store["active_provider"] = provider_id _save_auth_store(auth_store) def is_known_auth_provider(provider_id: str) -> bool: normalized = (provider_id or "").strip().lower() return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES def get_auth_provider_display_name(provider_id: str) -> str: normalized = (provider_id or "").strip().lower() if normalized in PROVIDER_REGISTRY: return PROVIDER_REGISTRY[normalized].name return SERVICE_PROVIDER_NAMES.get(normalized, provider_id) def is_runtime_provider_routable(provider_id: str) -> bool: """Return whether runtime resolution recognizes a provider identity. A capability check, not a credential check: same alias/plugin-aware normalization as ``resolve_provider`` while preserving special runtime identities that live outside the registry. """ normalized = (provider_id or "").strip().lower() if not normalized: return False if normalized in {"auto", "openrouter", "custom", "moa"}: return True if normalized.startswith("custom:"): return True try: resolve_provider(normalized) except AuthError: return False return True def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: """Return the persisted credential pool, or one provider slice. In profile mode, the profile's credential pool is authoritative. If a provider has no entries in the profile, entries from the global-root ``auth.json`` are used as a read-only fallback — so workers spawned in a profile can see providers that were only authenticated at global scope. Profile entries always win: the global fallback only applies per-provider when the profile has zero entries for that provider. Once the user runs ``hermes auth add `` inside the profile, profile entries fully shadow global for that provider on the next read. """ auth_store = _load_auth_store() pool = auth_store.get("credential_pool") if not isinstance(pool, dict): pool = {} global_pool: Dict[str, Any] = {} global_store = _load_global_auth_store() maybe_global_pool = global_store.get("credential_pool") if global_store else None if isinstance(maybe_global_pool, dict): global_pool = maybe_global_pool if provider_id is None: merged = dict(pool) for gp_key, gp_entries in global_pool.items(): if not isinstance(gp_entries, list) or not gp_entries: continue # Per-provider shadowing: profile wins whenever it has ANY entries. existing = merged.get(gp_key) if isinstance(existing, list) and existing: continue merged[gp_key] = list(gp_entries) return merged provider_entries = pool.get(provider_id) if isinstance(provider_entries, list) and provider_entries: return list(provider_entries) # Profile has no entries for this provider — fall back to global. global_entries = global_pool.get(provider_id) return list(global_entries) if isinstance(global_entries, list) else [] _POOL_STATUS_FIELDS = ( "last_status", "last_status_at", "last_error_code", "last_error_reason", "last_error_message", "last_error_reset_at", ) def _merge_disk_cooldown_state( entry: Dict[str, Any], disk_entry: Optional[Dict[str, Any]], provider_id: str, ) -> Dict[str, Any]: """Keep a newer on-disk cooldown/quarantine over a stale in-memory one. ``write_credential_pool`` callers persist an in-memory snapshot that may predate another process marking the same credential exhausted or dead (last-writer-wins lost update). Without this merge, process B's later rewrite resurrects a rate-limited key as healthy and both processes resume hammering it. """ if not isinstance(disk_entry, dict): return entry try: from agent.credential_pool import ( PooledCredential, STATUS_DEAD, STATUS_EXHAUSTED, _exhausted_until, _parse_absolute_timestamp, ) disk_status = disk_entry.get("last_status") if disk_status not in (STATUS_DEAD, STATUS_EXHAUSTED): return entry # A token change means the caller re-authed/refreshed this entry and # intentionally cleared its status (e.g. _sync_codex_entry_from_ # auth_store after a fresh device-code login) — never resurrect the # old cooldown onto fresh credentials. mem_access = entry.get("access_token") or "" disk_access = disk_entry.get("access_token") or "" if mem_access and disk_access and mem_access != disk_access: return entry disk_ts = _parse_absolute_timestamp(disk_entry.get("last_status_at")) or 0.0 mem_ts = _parse_absolute_timestamp(entry.get("last_status_at")) or 0.0 if disk_ts <= mem_ts: return entry if disk_status == STATUS_EXHAUSTED: until = _exhausted_until( PooledCredential.from_dict(provider_id, disk_entry) ) if until is None or until <= time.time(): return entry merged_entry = dict(entry) for status_field in _POOL_STATUS_FIELDS: merged_entry[status_field] = disk_entry.get(status_field) return merged_entry except Exception: # pragma: no cover - best-effort merge return entry def write_credential_pool( provider_id: str, entries: List[Dict[str, Any]], *, removed_ids: Optional[Iterable[str]] = None, ) -> Path: """Persist one provider's credential pool under auth.json. This is the final disk-boundary guard for borrowed/reference-only credentials. Callers may pass raw dictionaries, so sanitize here even when ``PooledCredential.to_dict()`` already did the same work upstream. Re-read the on-disk pool under the same lock and merge entries present on disk but missing from ``entries``. Those were added by another process after the caller loaded its in-memory snapshot; without this merge a later rotation/exhaustion rewrite drops the concurrent credential. """ removed = {rid for rid in (removed_ids or ()) if rid} with _auth_store_lock(): auth_store = _load_auth_store() pool = _store_section(auth_store, "credential_pool") sanitized_entries = [ sanitize_borrowed_credential_payload(entry, provider_id) if isinstance(entry, dict) else entry for entry in entries ] existing = pool.get(provider_id) existing_list = existing if isinstance(existing, list) else [] existing_by_id = { entry.get("id"): entry for entry in existing_list if isinstance(entry, dict) and entry.get("id") } new_ids = { entry.get("id") for entry in sanitized_entries if isinstance(entry, dict) and entry.get("id") } merged: List[Dict[str, Any]] = [ _merge_disk_cooldown_state( entry, existing_by_id.get(entry.get("id")), provider_id ) if isinstance(entry, dict) else entry for entry in sanitized_entries ] for disk_entry in existing_list: if not isinstance(disk_entry, dict): continue disk_id = disk_entry.get("id") if not disk_id or disk_id in new_ids or disk_id in removed: continue merged.append(sanitize_borrowed_credential_payload(disk_entry, provider_id)) pool[provider_id] = merged return _save_auth_store(auth_store) def suppress_credential_source(provider_id: str, source: str) -> None: """Mark a credential source as suppressed so it won't be re-seeded. Older auth stores may represent a provider's suppressed sources as a mapping. Treat its keys as source names and migrate the value to the canonical list form before appending the requested source. """ with _auth_store_lock(): auth_store = _load_auth_store() suppressed = _store_section(auth_store, "suppressed_sources") provider_list = _suppressed_source_list(suppressed, provider_id) if provider_list is None: provider_list = suppressed[provider_id] = [] if source not in provider_list: provider_list.append(source) _save_auth_store(auth_store) def _suppressed_source_list(suppressed: Dict[str, Any], provider_id: str) -> Optional[List[str]]: """Canonical (list-form) suppressed sources for *provider_id*, migrating a legacy mapping in place.""" raw_sources = suppressed.get(provider_id) if isinstance(raw_sources, list): return raw_sources if isinstance(raw_sources, dict): provider_list = [str(name) for name in raw_sources] suppressed[provider_id] = provider_list return provider_list return None def is_source_suppressed(provider_id: str, source: str) -> bool: """Check if a credential source has been suppressed by the user.""" try: auth_store = _load_auth_store() suppressed = auth_store.get("suppressed_sources", {}) return source in suppressed.get(provider_id, []) except Exception: return False def unsuppress_credential_source(provider_id: str, source: str) -> bool: """Clear a suppression marker so the source will be re-seeded on the next load.""" with _auth_store_lock(): auth_store = _load_auth_store() suppressed = auth_store.get("suppressed_sources") if not isinstance(suppressed, dict): return False provider_list = _suppressed_source_list(suppressed, provider_id) if provider_list is None or source not in provider_list: return False provider_list.remove(source) if not provider_list: suppressed.pop(provider_id, None) if not suppressed: auth_store.pop("suppressed_sources", None) _save_auth_store(auth_store) return True def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: """Return persisted auth state for a provider, or None. In profile mode, ``_load_provider_state`` already falls back to the global-root ``auth.json`` per-provider when the profile has no entry — so this is now a thin convenience wrapper. Profile state always wins when present. """ auth_store = _load_auth_store() return _load_provider_state(auth_store, provider_id) def get_active_provider() -> Optional[str]: """Return the currently active provider ID from auth store.""" auth_store = _load_auth_store() return auth_store.get("active_provider") def _active_provider_is(normalized: str) -> bool: active = (_load_auth_store().get("active_provider") or "").strip().lower() return bool(active) and active == normalized def _config_selects_provider(normalized: str) -> bool: """config.yaml ``model.provider``, or a MoA advisor/aggregator slot naming the provider. MoA presets are explicit model selections too: a user who configured ``provider: anthropic`` as a MoA slot has opted into Anthropic credentials for that slot even when the main session model is another provider. Without this, Claude Code OAuth entries are pruned by ``credential_pool.load_pool("anthropic")`` and MoA Anthropic advisors fail with "no ANTHROPIC_API_KEY" while the model picker says Anthropic is logged in. """ from hermes_cli.config import load_config cfg = load_config() model_cfg = cfg.get("model") if isinstance(model_cfg, dict) and (model_cfg.get("provider") or "").strip().lower() == normalized: return True def _slot_matches(slot: Any) -> bool: return isinstance(slot, dict) and (slot.get("provider") or "").strip().lower() == normalized def _moa_block_matches(block: Any) -> bool: return isinstance(block, dict) and ( any(_slot_matches(s) for s in block.get("reference_models") or []) or _slot_matches(block.get("aggregator")) ) moa_cfg = cfg.get("moa") if not isinstance(moa_cfg, dict): return False presets = moa_cfg.get("presets") return _moa_block_matches(moa_cfg) or ( isinstance(presets, dict) and any(_moa_block_matches(p) for p in presets.values()) ) def _explicit_pool_entry_present(normalized: str) -> bool: """Pool rows from EXPLICIT Hermes flows (manual add / device-code / PKCE) or live env keys; ambient borrowed sources (gh_cli / claude_code / qwen-cli) are deliberately excluded.""" return any(_pool_entry_is_explicit(entry) for entry in read_credential_pool(normalized)) # Set by Claude Code itself, not by the user explicitly configuring anthropic in Hermes. _IMPLICIT_ENV_VARS = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) _EXPLICIT_POOL_SOURCES = frozenset({"device_code", "loopback_pkce", "hermes_pkce", "manual"}) _VERTEX_PROVIDER_IDS = ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai") def _explicit_env_credentials_present(normalized: str) -> bool: """True when the user has pasted an explicit credential env var for *normalized*. Falls back to the models.dev ``ProviderDef`` when the provider isn't in PROVIDER_REGISTRY (e.g. openrouter) — both expose ``.auth_type`` / ``.api_key_env_vars`` with the same shape. AWS SDK providers (Bedrock) have empty ``api_key_env_vars``, so check their explicit env credentials directly — NOT boto3's full chain: ambient sources like EC2 IMDS / SSO profiles must not auto-surface, but AWS_BEARER_TOKEN_BEDROCK or an access-key pair in .env is as explicit as pasting ANTHROPIC_API_KEY. """ pconfig = PROVIDER_REGISTRY.get(normalized) if pconfig is None: from hermes_cli.providers import get_provider pconfig = get_provider(normalized) if not pconfig: return False if pconfig.auth_type == "api_key": return any( has_usable_secret(os.getenv(env_var, "")) for env_var in pconfig.api_key_env_vars if env_var not in _IMPLICIT_ENV_VARS ) if pconfig.auth_type == "aws_sdk": return has_usable_secret(os.getenv("AWS_BEARER_TOKEN_BEDROCK", "")) or ( has_usable_secret(os.getenv("AWS_ACCESS_KEY_ID", "")) and has_usable_secret(os.getenv("AWS_SECRET_ACCESS_KEY", "")) ) return False def _pool_entry_is_explicit(entry: Any) -> bool: """True for pool rows the user created via an explicit Hermes flow (or a still-live env key).""" if not isinstance(entry, dict): return False source = str(entry.get("source") or "").strip().lower() if not source: return False if source.startswith("env:"): # A stale env-seeded pool entry survives in auth.json after # the user deletes the env var (#55790) — only count it when # the referenced var still resolves to a usable secret NOW. env_var = entry.get("source", "").split(":", 1)[1].strip() return bool(env_var and has_usable_secret(os.getenv(env_var, ""))) return source in _EXPLICIT_POOL_SOURCES or source.startswith("manual:") def _keyless_provider_has_explicit_config(normalized: str) -> bool: """Vertex / Bedrock count as explicit when Hermes-scoped routing config is present. Uses has_explicit_vertex_config(), NOT has_vertex_credentials() — the latter also counts an ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set globally for unrelated GCP work), which would mark Vertex explicit for users who never set Hermes up for it. Only Hermes-scoped signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) count here. """ if normalized in _VERTEX_PROVIDER_IDS: from agent.vertex_adapter import has_explicit_vertex_config return bool(has_explicit_vertex_config()) if normalized == "bedrock": from hermes_cli.config import load_config as _load_cfg bedrock_cfg = _load_cfg().get("bedrock") return isinstance(bedrock_cfg, dict) and bool(str(bedrock_cfg.get("region") or "").strip()) return False # Ordered explicit-configuration checks: ``(check, best_effort)``. Best-effort checks treat an # exception as "no"; the env-var check is NOT best-effort — a failure there must surface rather # than let a later, weaker signal decide. _EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = ( (_active_provider_is, True), (_config_selects_provider, True), (_explicit_env_credentials_present, False), (_explicit_pool_entry_present, True), (_keyless_provider_has_explicit_config, True), ) def is_provider_explicitly_configured(provider_id: str) -> bool: """Return True only if the user has explicitly configured this provider. Explicit = auth.json ``active_provider``, config.yaml ``model.provider`` / MoA slots, a pasted provider env var, a pool entry from a Hermes-initiated flow, or Hermes-scoped routing config for keyless cloud-SDK providers. Ambient borrowed credentials (gh CLI, qwen-cli, Claude Code's ~/.claude/.credentials.json) never count, so they are never used without the user's choice. """ normalized = (provider_id or "").strip().lower() for check, best_effort in _EXPLICIT_CONFIG_CHECKS: try: if check(normalized): return True except Exception as exc: if not best_effort: raise logger.debug("explicit-config check %s failed for %s: %s", check.__name__, provider_id, exc) return False def clear_provider_auth(provider_id: Optional[str] = None) -> bool: """Clear auth state for a provider. Used by `hermes logout`. If provider_id is None, clears the active provider. Returns True if something was cleared. """ with _auth_store_lock(): auth_store = _load_auth_store() target = provider_id or auth_store.get("active_provider") if not target: return False cleared = False for section in ("providers", "credential_pool"): entries = _store_section(auth_store, section) if target in entries: del entries[target] cleared = True if auth_store.get("active_provider") == target: auth_store["active_provider"] = None cleared = True if not cleared: return False _save_auth_store(auth_store) return True def deactivate_provider() -> None: """Clear active_provider in auth.json without deleting credentials. Used when the user switches to a non-OAuth provider (OpenRouter, custom) so auto-resolution doesn't keep picking the OAuth provider. """ with _auth_store_lock(): auth_store = _load_auth_store() auth_store["active_provider"] = None _save_auth_store(auth_store) # ============================================================================= # Provider Resolution — picks which provider to use # ============================================================================= def _get_config_hint_for_unknown_provider(provider_name: str) -> str: """Return a helpful hint string when provider resolution fails.""" try: from hermes_cli.config import validate_config_structure issues = validate_config_structure() if not issues: return "" lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"] for ci in issues: prefix = "ERROR" if ci.severity == "error" else "WARNING" lines.append(f" [{prefix}] {ci.message}") # Show first line of hint first_hint = ci.hint.splitlines()[0] if ci.hint else "" if first_hint: lines.append(f" → {first_hint}") return "\n".join(lines) except Exception: return "" def _refuse_env_adoption_if_config_corrupt() -> None: """Refuse env-key/pool auto-adoption of openrouter while config.yaml is corrupt. When ``~/.hermes/config.yaml`` EXISTS but fails to parse, ``load_config()`` falls back to ``DEFAULT_CONFIG`` — so the tier-2 config check above finds no ``model.provider`` and the env- var sniff / pool probe silently adopts the PAID openrouter provider, even though the user's real (broken) config may name a completely different provider (e.g. a free local one). This probe fires ONLY on the auto path — explicitly requested providers never reach it — and clears itself as soon as the file changes (a fixed config resolves normally on the next call). """ try: from hermes_cli.config import get_active_config_parse_failure, get_config_path err = get_active_config_parse_failure() if not err: return path = get_config_path() except Exception as e: logger.debug("Could not probe config parse-failure state: %s", e) return raise AuthError( f"config.yaml at {path} is corrupt ({err}) — refusing to auto-select " f"an inference provider from environment keys. Fix the YAML (a backup " f"was saved next to it) or run hermes setup.", code="corrupt_config", ) # Provider aliases accepted by resolve_provider(). Plugin-declared aliases # (plugins/model-providers//) are layered on at call time; this hardcoded # table remains authoritative for existing names. _PROVIDER_ALIASES: Dict[str, str] = { "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", "x-ai": "xai", "x.ai": "xai", "grok": "xai", "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", "grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", "step": "stepfun", "stepfun-coding-plan": "stepfun", "arcee-ai": "arcee", "arceeai": "arcee", "gmi-cloud": "gmi", "gmicloud": "gmi", "actual-computer": "actual", "actualcomputer": "actual", "aci": "actual", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth", "alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan", "alibaba_coding_plan": "alibaba-coding-plan", "claude": "anthropic", "claude-code": "anthropic", "github": "copilot", "github-copilot": "copilot", "github-models": "copilot", "github-model": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", "free": "opencode-free", "opencode_free": "opencode-free", "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub", "tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub", "tokenplan": "tencent-tokenplan", "tencent-lkeap": "tencent-tokenplan", "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock", "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio", # Local server aliases — route through the generic custom provider "ollama": "custom", "ollama_cloud": "ollama-cloud", "vllm": "custom", "llamacpp": "custom", "llama.cpp": "custom", "llama-cpp": "custom", } def _scoped_key_env_reader() -> Callable[[str], str]: """Scope-aware key reader for provider auto-detection. Under multiplex a secondary profile's API keys live only in its secret scope, not os.environ — a bare getenv would find nothing and auto-resolution would report "No LLM provider configured" for every secondary profile (same class as #86905). Catch ONLY ImportError: any other failure inside auxiliary_client must propagate — silently falling back to os.getenv would reintroduce the very fail-open this removes, with zero trace. """ try: from agent.auxiliary_client import _scoped_key_env return _scoped_key_env except ImportError: logger.warning( "agent.auxiliary_client unavailable (%s); provider auto-detection " "will read keys from the process environment only — under " "multiplex, secondary profiles may report 'No LLM provider'.", "import failed", ) return lambda name: os.getenv(name) or "" def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: """True when an OpenRouter credential exists via env key or the credential pool. The pool check covers a key added via `hermes auth add openrouter` (manual pool entry, no env var). Without it, a pool-only key is invisible to auto-detection — `hermes auth list` shows the credential while requests go out with no Authorization header (#42130). """ if has_usable_secret(scoped_key_env("OPENAI_API_KEY")) or has_usable_secret( scoped_key_env("OPENROUTER_API_KEY") ): return True try: from agent.credential_pool import load_pool as _load_pool return bool(_load_pool("openrouter").has_credentials()) except Exception as e: logger.debug("Could not check OpenRouter credential pool: %s", e) return False def _logged_in_oauth_active_provider() -> Optional[str]: """auth.json ``active_provider`` when it is a registry provider that reports logged in.""" try: _store = _load_auth_store() _maybe = _store.get("active_provider") if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"): return _maybe except Exception as e: logger.debug("Could not pre-read active auth provider: %s", e) return None def resolve_provider( requested: Optional[str] = None, *, explicit_api_key: Optional[str] = None, explicit_base_url: Optional[str] = None, ) -> str: """Determine which inference provider to use. Priority when requested is "auto"/None — explicit user intent wins over a stale logged-in OAuth provider (#29285): 1. explicit CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY env -> "openrouter"; 4. OpenRouter credential pool; 5. provider-specific env keys; 6. auth.json ``active_provider`` (OAuth); 7. AWS Bedrock credential chain; 8. AuthError(no_provider_configured). """ normalized = (requested or "auto").strip().lower() # Normalize provider aliases. Extend with aliases declared in # plugins/model-providers// that aren't already mapped. aliases = dict(_PROVIDER_ALIASES) try: from providers import list_providers as _lp for _pp in _lp(): for _alias in _pp.aliases: if _alias not in aliases: aliases[_alias] = _pp.name except Exception: pass normalized = aliases.get(normalized, normalized) if normalized == "openrouter": return "openrouter" if normalized == "custom": return "custom" if normalized in PROVIDER_REGISTRY: return normalized if normalized != "auto": # Check for common config.yaml issues that cause this error _config_hint = _get_config_hint_for_unknown_provider(normalized) msg = f"Unknown provider '{normalized}'." if _config_hint: msg += f"\n\n{_config_hint}" else: msg += " Check 'hermes model' for available providers, or run 'hermes doctor' to diagnose config issues." raise AuthError(msg, code="invalid_provider") # Explicit one-off CLI creds always mean openrouter/custom if explicit_api_key or explicit_base_url: return "openrouter" # Tier 2. The normal chat/gateway path resolves config.provider upstream in # resolve_requested_provider() before ever reaching "auto"; this duplicate # check is the safety net for the lone direct caller (main.py resolve_provider # ("auto")) and any future bypass of that stage. _model_cfg: Any = None try: from hermes_cli.config import load_config _model_cfg = (load_config() or {}).get("model") if isinstance(_model_cfg, dict): _cfg_provider = _model_cfg.get("provider") if isinstance(_cfg_provider, str) and _cfg_provider.strip().lower() in PROVIDER_REGISTRY: return _cfg_provider.strip().lower() except Exception as e: logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e) _scoped_key_env = _scoped_key_env_reader() # Tiers 3-4: OPENAI/OPENROUTER env keys, then the OpenRouter credential pool. if _openrouter_auto_detected(_scoped_key_env): _refuse_env_adoption_if_config_corrupt() return "openrouter" # Determine the logged-in OAuth provider up front so the env-key loop below # can WARN when an exported API key preempts it (#29285 transparency). The # actual OAuth fallback (tier 6) still happens later if nothing else matches. _oauth_active = _logged_in_oauth_active_provider() # Auto-detect API-key providers by checking their env vars for pid, pconfig in PROVIDER_REGISTRY.items(): if pconfig.auth_type != "api_key": continue # GitHub tokens are commonly present for repo/tool access but should not # hijack inference auto-selection unless the user explicitly chooses # Copilot/GitHub Models as the provider. LM Studio is a local server # whose availability isn't implied by LM_API_KEY presence (it may be # offline, and the no-auth setup uses a placeholder value), so it # also requires explicit selection. if pid in {"copilot", "lmstudio"}: continue for env_var in pconfig.api_key_env_vars: if has_usable_secret(_scoped_key_env(env_var)): # An exported API key now wins over a logged-in OAuth provider # (the #29285 fix). Surface that so a user who deliberately uses # OAuth but has a stale key in ~/.hermes/.env isn't silently # switched without knowing why. if _oauth_active and _oauth_active != pid: logger.warning( "Provider resolved to %r via %s, preempting your " "logged-in OAuth provider %r. If you meant to use the " "OAuth login, unset %s or set `model.provider` " "explicitly.", pid, env_var, _oauth_active, env_var, ) return pid # Logged-in OAuth provider (auth.json `active_provider`) — a LAST-RESORT # fallback, chosen only when the user expressed no other preference above. # Previously this sat ABOVE the env-var/config checks, so a stale OAuth # login silently overrode an explicit `model.provider` or an exported API # key (#29285). Demoted here so explicit intent always wins. if _oauth_active: # Surface the silent-override case the issue reported: a populated # `model` config that lacks a `provider` key falls through to OAuth. if isinstance(_model_cfg, dict) and _model_cfg and not _model_cfg.get("provider"): logger.warning( "Provider resolved to logged-in OAuth provider %r because " "config.yaml `model` has no `provider` key. If you meant a " "different provider, set `model.provider` explicitly.", _oauth_active, ) return _oauth_active # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars). # This runs after API-key providers so explicit keys always win. try: from agent.bedrock_adapter import has_aws_credentials if has_aws_credentials(): return "bedrock" except ImportError: pass # boto3 not installed — skip Bedrock auto-detection raise AuthError( "No inference provider configured. Run 'hermes model' to choose a " "provider and model, or set an API key (OPENROUTER_API_KEY, " "OPENAI_API_KEY, etc.) in ~/.hermes/.env.", code="no_provider_configured", ) # ============================================================================= # Timestamp / TTL helpers # ============================================================================= def _utc_now_z() -> str: """Current UTC time as an ISO-8601 string with a ``Z`` suffix (last_refresh format).""" return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _parse_iso_timestamp(value: Any) -> Optional[float]: if not isinstance(value, str) or not value: return None text = value.strip() if not text: return None if text.endswith("Z"): text = text[:-1] + "+00:00" try: parsed = datetime.fromisoformat(text) except Exception: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.timestamp() def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool: expires_epoch = _parse_iso_timestamp(expires_at_iso) if expires_epoch is None: return True return expires_epoch <= (time.time() + skew_seconds) def _tls_state_from_verify(verify: Any) -> Dict[str, Any]: """Persistable ``tls`` block derived from an httpx ``verify`` value.""" return { "insecure": verify is False, "ca_bundle": verify if isinstance(verify, str) else None, } def _last_auth_error_marker( provider: str, error: "AuthError", *, reason: str, default_code: Optional[str] = None, ) -> Dict[str, Any]: """The ``last_auth_error`` record persisted when dead OAuth material is quarantined.""" return { "provider": provider, "code": error.code if default_code is None else (error.code or default_code), "message": str(error), "reason": reason, "relogin_required": True, "at": datetime.now(timezone.utc).isoformat(), } _FLAT_OAUTH_TOKEN_KEYS = ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at") def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "AuthError") -> None: """Strip dead tokens from a flat OAuth state after a terminal runtime refresh failure. Mirrors the Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a network retry. """ for _k in _FLAT_OAUTH_TOKEN_KEYS: state.pop(_k, None) state["last_auth_error"] = _last_auth_error_marker( provider, exc, reason="runtime_refresh_failure", default_code="refresh_failed", ) def _coerce_ttl_seconds(expires_in: Any) -> int: try: ttl = int(expires_in) except Exception: ttl = 0 return max(0, ttl) def _optional_base_url(value: Any) -> Optional[str]: if not isinstance(value, str): return None cleaned = value.strip().rstrip("/") return cleaned if cleaned else None # Allowlist of valid Nous Portal hosts. A portal_base_url outside this # set is treated as a misconfiguration and falls back to the default. # "localhost" / "127.0.0.1" are valid for local development and testing. _NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ "portal.nousresearch.com", "localhost", "127.0.0.1", }) # Per-process memo for resolve_nous_access_token. Startup runs # check_tool_availability once per managed-tool check_fn (browser, image_gen, # etc.), and each one independently triggers a ~15s blocking token-refresh # network call when the stored token is expired. On a slow/constrained host that # serial burst stretches startup to many minutes. A short-TTL memo collapses the # burst into a single network round-trip; callers that need freshness use # separate flows (force_fresh / refresh_nous_oauth_pure) and are unaffected. _RESOLVE_TOKEN_CACHE_LOCK = threading.Lock() _RESOLVE_TOKEN_CACHE: "tuple[float, str] | None" = None _RESOLVE_TOKEN_CACHE_TTL_S = 5.0 def resolve_nous_access_token( *, timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) -> str: """Resolve a refresh-aware Nous Portal access token for managed tool gateways.""" global _RESOLVE_TOKEN_CACHE # Memo: collapse the startup burst of managed-tool check_fns into one # network refresh. Only cache a successful, non-forced resolution for a # short window; force_fresh / error paths bypass and don't populate it. if not insecure and ca_bundle is None: with _RESOLVE_TOKEN_CACHE_LOCK: if _RESOLVE_TOKEN_CACHE is not None: cached_at, cached_token = _RESOLVE_TOKEN_CACHE if (time.monotonic() - cached_at) < _RESOLVE_TOKEN_CACHE_TTL_S: return cached_token with _provider_state_transaction("nous") as ( auth_store, state, state_source_path, ): if not state: raise _nous_err("Hermes is not logged into Nous Portal.", relogin=True) # HERMES_PORTAL_BASE_URL / NOUS_PORTAL_BASE_URL is the trusted # operator/deployment override (mirrors NOUS_INFERENCE_BASE_URL) and # must win OUTRIGHT — including over a stored value — and bypass the # host allowlist entirely, since the allowlist exists to reject an # untrusted network-provided value, not one the operator configured. # Only fall through to the stored/default value + allowlist gate when # no override is set. env_portal_override = _nous_portal_env_override() if env_portal_override: portal_base_url = env_portal_override.rstrip("/") else: portal_base_url = ( _optional_base_url(state.get("portal_base_url")) or DEFAULT_NOUS_PORTAL_URL ).rstrip("/") parsed_portal_url = urlparse(portal_base_url) if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: logger.warning( "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", portal_base_url, parsed_portal_url.hostname, ) portal_base_url = DEFAULT_NOUS_PORTAL_URL client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): merged_shared = _merge_shared_nous_oauth_state(state) access_token = state.get("access_token") refresh_token = state.get("refresh_token") if not isinstance(access_token, str) or not access_token: raise _nous_err( "No access token found for Nous Portal login.", relogin=True, ) if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): if merged_shared: _save_provider_state_to_source(auth_store, "nous", state, state_source_path) # Populate the memo on the valid-token fast path too: the # startup burst usually finds a *valid* token, but each # check_fn call still pays two cross-process file locks and # state reads to reach this return. The token has at least # refresh_skew_seconds (>= 120s) of life here, so a 5s memo # can never serve an expired token. if not insecure and ca_bundle is None: with _RESOLVE_TOKEN_CACHE_LOCK: _RESOLVE_TOKEN_CACHE = (time.monotonic(), access_token) return access_token if not isinstance(refresh_token, str) or not refresh_token: raise _nous_err( "Session expired and no refresh token is available.", relogin=True, ) timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) with httpx.Client( timeout=timeout, headers={"Accept": "application/json"}, verify=verify, ) as client: refreshed = _refresh_nous_or_quarantine( client=client, auth_store=auth_store, state=state, portal_base_url=portal_base_url, client_id=client_id, refresh_token=refresh_token, reason="managed_access_token_refresh_failure", persist=lambda: _save_provider_state_to_source( auth_store, "nous", state, state_source_path ), ) _apply_nous_refreshed_tokens(state, refreshed, refresh_token) state["portal_base_url"] = portal_base_url state["client_id"] = client_id state["tls"] = _tls_state_from_verify(verify) _save_provider_state_to_source(auth_store, "nous", state, state_source_path) _write_shared_nous_state(state) resolved = state["access_token"] if not insecure and ca_bundle is None: with _RESOLVE_TOKEN_CACHE_LOCK: _RESOLVE_TOKEN_CACHE = (time.monotonic(), resolved) return resolved # ============================================================================= # Status helpers # ============================================================================= # ── Process-level memo for get_nous_auth_status() ── # get_nous_auth_status() validates state by calling resolve_nous_runtime_credentials(), # which does a synchronous OAuth refresh POST to portal.nousresearch.com. That can take # ~350ms even on the failure path, and read-only UI surfaces (`hermes tools`, status panels, # subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms" # was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning # single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json # path + mtime so that profile switches do not share a process memo and # `hermes auth login/logout/add/remove` invalidate naturally on the next call. _NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds _nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None # mtime-keyed memo for _load_global_auth_store(): (path, mtime_ns, store). # Same invalidation contract as _nous_auth_status_cache — the global auth # file changes only when a global-scope auth write touches it. _global_auth_store_cache: Optional[Tuple[str, int, Dict[str, Any]]] = None def _auth_file_cache_key() -> Tuple[str, Optional[float]]: auth_file = _auth_file_path() try: auth_file_key = str(auth_file.resolve(strict=False)) except Exception: auth_file_key = str(auth_file) try: return auth_file_key, auth_file.stat().st_mtime except Exception: # missing file included: key without an mtime return auth_file_key, None def invalidate_nous_auth_status_cache() -> None: """Clear the get_nous_auth_status() process-level memo. Call from code paths that mutate Nous auth state without going through ``resolve_nous_runtime_credentials()`` (e.g. tests). Login/logout touch auth.json, so the mtime check invalidates them automatically; this is the belt-and-braces option. """ global _nous_auth_status_cache _nous_auth_status_cache = None def get_nous_auth_status() -> Dict[str, Any]: """Status snapshot for Nous auth. Prefer the auth-store provider state, because that is the live source of truth for refresh operations. When provider state exists, validate it by resolving runtime credentials so revoked refresh sessions do not show up as a healthy login. The returned snapshot is memoised for ~15s keyed on the auth.json mtime, so menu/status surfaces that ask repeatedly don't trigger one refresh POST per call. Login/logout flows write to auth.json and therefore invalidate the cache automatically; tests can also call ``invalidate_nous_auth_status_cache()`` explicitly. """ global _nous_auth_status_cache now = time.monotonic() auth_file_key, mtime = _auth_file_cache_key() cached = _nous_auth_status_cache if cached is not None: cached_at, cached_auth_file_key, cached_mtime, cached_status = cached if ( cached_auth_file_key == auth_file_key and cached_mtime == mtime and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL ): return dict(cached_status) status = _compute_nous_auth_status() _nous_auth_status_cache = (now, auth_file_key, mtime, dict(status)) return status @dataclass(frozen=True) class OAuthProviderFlow: """Per-provider OAuth plumbing, keyed by provider id in ``OAUTH_PROVIDER_FLOWS``. Entries name module-level callables (strings) rather than binding them, so ``monkeypatch.setattr("hermes_cli.auth.resolve_codex_runtime_credentials", ...)`` and friends keep intercepting: ``resolve()`` / ``status()`` look the name up in this module at call time. """ provider_id: str resolve_fn: str status_fn: str # AuthError codes after which retrying the same refresh token cannot succeed. terminal_refresh_codes: FrozenSet[str] = frozenset() # ``hermes logout`` with no active provider falls back to config.yaml ``model.provider`` # only for providers whose credentials live in auth.json. logout_from_config: bool = False def resolve(self, **kwargs: Any) -> Dict[str, Any]: return globals()[self.resolve_fn](**kwargs) def status(self) -> Dict[str, Any]: return globals()[self.status_fn]() def is_terminal_refresh_error(self, exc: Exception) -> bool: return ( isinstance(exc, AuthError) and exc.provider == self.provider_id and exc.code in self.terminal_refresh_codes and bool(exc.relogin_required) ) _OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"}) OAUTH_PROVIDER_FLOWS: Dict[str, OAuthProviderFlow] = { "nous": OAuthProviderFlow( "nous", "resolve_nous_runtime_credentials", "get_nous_auth_status", terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES, logout_from_config=True, ), "openai-codex": OAuthProviderFlow( "openai-codex", "resolve_codex_runtime_credentials", "get_codex_auth_status", terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES | {"codex_refresh_failed", "codex_auth_missing_refresh_token"}, logout_from_config=True, ), "xai-oauth": OAuthProviderFlow( "xai-oauth", "resolve_xai_oauth_runtime_credentials", "get_xai_oauth_auth_status", terminal_refresh_codes=frozenset({"xai_refresh_failed", "xai_auth_missing_refresh_token"}), logout_from_config=True, ), "qwen-oauth": OAuthProviderFlow("qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status"), "minimax-oauth": OAuthProviderFlow("minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status"), } def _is_terminal_refresh_error(exc: Exception, provider: str) -> bool: """True when retrying the same *provider* refresh token cannot succeed.""" return OAUTH_PROVIDER_FLOWS[provider].is_terminal_refresh_error(exc) def _is_terminal_nous_refresh_error(exc: Exception) -> bool: return _is_terminal_refresh_error(exc, "nous") def _is_terminal_xai_oauth_refresh_error(exc: Exception) -> bool: return _is_terminal_refresh_error(exc, "xai-oauth") def _is_terminal_codex_oauth_refresh_error(exc: Exception) -> bool: return _is_terminal_refresh_error(exc, "openai-codex") def _codex_pool_rate_limited_status() -> Optional[Dict[str, Any]]: rate_limit = _codex_pool_rate_limit_status() if not rate_limit: return None return { "logged_in": True, "auth_store": str(_auth_file_path()), "last_refresh": rate_limit.get("last_refresh"), "auth_mode": "chatgpt", "source": f"pool:{rate_limit.get('label') or 'unknown'}", "rate_limited": True, "error_code": CODEX_RATE_LIMITED_CODE, "error": ( rate_limit.get("message") or "Codex provider quota exhausted; retry after the usage limit resets." ), "reset_at": rate_limit.get("reset_at"), } def get_codex_auth_status() -> Dict[str, Any]: """Status snapshot for Codex auth (pool first, then legacy provider state).""" return _pool_first_oauth_status( "openai-codex", is_expiring=_codex_access_token_is_expiring, auth_mode="chatgpt", resolve=resolve_codex_runtime_credentials, on_pool_miss=_codex_pool_rate_limited_status, ) def get_xai_oauth_auth_status() -> Dict[str, Any]: return _pool_first_oauth_status( "xai-oauth", is_expiring=_xai_access_token_is_expiring, # Display/telemetry only. Device-code is the only xAI OAuth flow, so report it # unconditionally (auth.json may still carry a legacy ``oauth_pkce`` label). auth_mode="oauth_device_code", resolve=resolve_xai_oauth_runtime_credentials, ) def _provider_env_base_url(pconfig: ProviderConfig) -> str: return os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: """Status snapshot for API-key providers (z.ai, Kimi, MiniMax).""" pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "api_key": return {"configured": False} # Keyless providers (opencode-free) are served anonymously: no credential # exists, so every install counts as configured/logged in. Derived from # the HermesOverlay keyless flag — the same source the provider catalog # and GUI contract tests use. try: from hermes_cli.providers import HERMES_OVERLAYS _overlay = HERMES_OVERLAYS.get(provider_id) except Exception: _overlay = None if _overlay is not None and getattr(_overlay, "keyless", False): return { "configured": True, "provider": provider_id, "name": pconfig.name, "key_source": "keyless", "base_url": pconfig.inference_base_url, "logged_in": True, } api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) env_url = _provider_env_base_url(pconfig) if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url else: base_url = pconfig.inference_base_url if provider_id == "actual": base_url = normalize_actual_base_url(base_url) actual_local_noauth = ( provider_id == "actual" and not api_key and is_actual_local_base_url(base_url) ) return { "configured": bool(api_key) or actual_local_noauth, "provider": provider_id, "name": pconfig.name, "key_source": key_source or ("local-offline" if actual_local_noauth else ""), "base_url": base_url, "logged_in": bool(api_key) or actual_local_noauth, # compat with OAuth status shape } def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[str]]: """Best-effort POSITIVE evidence that an external-process provider's CLI is authenticated. Returns ``(verified, source)``. ``verified`` is only ever True on hard evidence (a supported env token, or a known on-disk credential store). False means "not verifiable from here", NOT "signed out" — the Copilot CLI may hold its session in an OS keychain Hermes can't read. Callers must therefore treat False as unknown, never as proof of absence. Deliberately subprocess-free: this runs from status endpoints and pickers, and spawning ``gh auth token`` there re-creates the cold-start stall (#60800) that copilot_auth.py works to avoid. """ if provider_id != "copilot-acp": return False, None # 1. Supported env tokens — the same vars the Copilot CLI itself honors. try: from hermes_cli.copilot_auth import COPILOT_ENV_VARS, validate_copilot_token for env_var in COPILOT_ENV_VARS: val = os.getenv(env_var, "").strip() if val and validate_copilot_token(val)[0]: return True, f"env: {env_var}" except Exception as exc: logger.debug("copilot-acp env token evidence check failed: %s", exc) # 2. The Copilot CLI's own plaintext token store (~/.copilot/config.json, # written by `copilot login` when no OS keychain is available). The file # is JSONC — strip //-comment lines before parsing. try: cli_config = os.path.expanduser("~/.copilot/config.json") if os.path.isfile(cli_config): with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh: raw = "\n".join( line for line in fh.read().splitlines() if not line.lstrip().startswith("//") ) data = json.loads(raw) if raw.strip() else {} tokens = data.get("copilotTokens") if isinstance(tokens, dict) and any( isinstance(v, str) and v.strip() for v in tokens.values() ): return True, "~/.copilot/config.json" except Exception as exc: logger.debug("copilot-acp CLI config evidence check failed: %s", exc) # 3. Known on-disk GitHub Copilot credential stores (the same locations # models.py already fingerprints as external credential files). for cred_path in ( "~/.config/github-copilot/hosts.json", "~/.config/github-copilot/apps.json", ): try: expanded = os.path.expanduser(cred_path) if os.path.isfile(expanded) and os.path.getsize(expanded) > 2: return True, cred_path except OSError: continue return False, None def _external_process_spec( pconfig: ProviderConfig, ) -> tuple[str, List[str], str, Optional[str], tuple[str, ...]]: """``(command, args, base_url, resolved_command, command_env_vars)`` for a subprocess-backed (ACP) provider. How to launch the CLI comes from the provider's own profile, so a provider shipped outside this tree describes its binary/args instead of inheriting another vendor's. copilot-acp's values live in its profile, which is why HERMES_COPILOT_ACP_COMMAND / COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS keep working unchanged. """ base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" if not base_url: base_url = pconfig.inference_base_url try: from providers import get_provider_profile as _get_provider_profile profile = _get_provider_profile(pconfig.id) except Exception: profile = None command_env_vars = tuple(getattr(profile, "process_command_env_vars", ()) or ()) args_env_var = str(getattr(profile, "process_args_env_var", "") or "") command = next((v for v in (os.getenv(var, "").strip() for var in command_env_vars) if v), "") if not command: command = str(getattr(profile, "process_command", "") or "") raw_args = os.getenv(args_env_var, "").strip() if args_env_var else "" args = shlex.split(raw_args) if raw_args else list(getattr(profile, "process_args", ()) or []) resolved_command = shutil.which(command) if command else None return command, args, base_url, resolved_command, command_env_vars def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: """Status snapshot for providers that run a local subprocess. ``configured``/``logged_in`` stay structural (the executable resolves or a TCP endpoint is set) because the spawned subprocess owns its real auth. ``auth_verified``/``auth_source`` carry positive credential evidence when Hermes can actually see some — absence of evidence is not absence of auth. """ pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "external_process": return {"configured": False} command, args, base_url, resolved_command, _ = _external_process_spec(pconfig) available = bool(resolved_command or base_url.startswith("acp+tcp://")) auth_verified, auth_source = _external_process_auth_evidence(provider_id) return { "configured": available, "provider": provider_id, "name": pconfig.name, "command": command, "args": args, "resolved_command": resolved_command, "base_url": base_url, "logged_in": available, "auth_verified": auth_verified, "auth_source": auth_source, } def _get_aws_sdk_auth_status(target: str) -> Dict[str, Any]: """AWS SDK providers (Bedrock) — check via boto3 credential chain.""" try: from agent.bedrock_adapter import has_aws_credentials return {"logged_in": has_aws_credentials(), "provider": target} except ImportError: return {"logged_in": False, "provider": target, "error": "boto3 not installed"} def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: """Generic auth status dispatcher. Per-provider OAuth status builders come from ``OAUTH_PROVIDER_FLOWS`` (plus Spotify / Azure Foundry bespoke builders); everything else dispatches on the registry ``auth_type`` so a provider class (e.g. every external-process ACP backend) gets a real status instead of the ``{"logged_in": False}`` fallthrough. Builders are looked up by NAME at call time so tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply. """ target = (provider_id or get_active_provider() or "").strip().lower() if not target: return {"logged_in": False} status_fn_name = _BESPOKE_STATUS_FUNCTIONS.get(target) if status_fn_name: return globals()[status_fn_name]() pconfig = PROVIDER_REGISTRY.get(target) if pconfig and pconfig.auth_type in _STATUS_BY_AUTH_TYPE: return globals()[_STATUS_BY_AUTH_TYPE[pconfig.auth_type]](target) return {"logged_in": False} # Bespoke status builders (name -> looked up in this module at call time) win over the # auth_type-keyed fallbacks below. _BESPOKE_STATUS_FUNCTIONS: Dict[str, str] = { **{pid: flow.status_fn for pid, flow in OAUTH_PROVIDER_FLOWS.items()}, "spotify": "get_spotify_auth_status", "azure-foundry": "_get_azure_foundry_auth_status", } _STATUS_BY_AUTH_TYPE: Dict[str, str] = { "external_process": "get_external_process_provider_status", "api_key": "get_api_key_provider_status", "aws_sdk": "_get_aws_sdk_auth_status", } def _get_azure_foundry_auth_status() -> Dict[str, Any]: """Return structural auth status for Azure Foundry. * ``auth_mode == "entra_id"`` AND ``azure-identity`` is importable (we do NOT mint a token here; ``hermes doctor`` runs the live probe and reports whether the credential chain can acquire one). * ``auth_mode == "api_key"`` (default) AND ``AZURE_FOUNDRY_API_KEY`` is set with a usable value. Never invokes the Entra credential chain — keeps CLI startup latency flat regardless of token- service / az login state. """ info: Dict[str, Any] = {"provider": "azure-foundry"} try: from hermes_cli.config import load_config, get_env_value_prefer_dotenv cfg = load_config() except Exception: cfg = {} model_cfg = cfg.get("model") if isinstance(cfg, dict) else None auth_mode = "api_key" base_url = "" if isinstance(model_cfg, dict): auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key" base_url = str(model_cfg.get("base_url") or "").strip() info["auth_mode"] = auth_mode info["base_url"] = base_url if auth_mode == "entra_id": try: from agent.azure_identity_adapter import ( EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed, ) installed = has_azure_identity_installed() entra_cfg = {} if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict): entra_cfg = model_cfg["entra"] identity_config = EntraIdentityConfig.from_dict( entra_cfg, default_scope=SCOPE_AI_AZURE_DEFAULT, ) info["azure_identity_installed"] = installed info["scope"] = identity_config.scope info["credential_probe"] = "not_run" info["credential_verified"] = False info["logged_in"] = bool(installed) if not installed: info["hint"] = ( "azure-identity not installed. Install with: " "pip install azure-identity (or rely on Hermes' " "lazy-install at first use)." ) else: info["hint"] = ( "azure-identity is installed; live credential validation " "is skipped here. Run `hermes doctor` to verify token acquisition." ) return info except Exception as exc: info["logged_in"] = False info["error"] = f"azure-identity check failed: {exc}" return info # api_key mode (default) try: api_key = get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY") or "" except Exception: api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "") info["logged_in"] = has_usable_secret(api_key) return info def _default_api_key_base_url(api_key: str, default: str, env_url: str) -> str: return env_url.rstrip("/") if env_url else default def _copilot_runtime_base_url(api_key: str, default: str, env_url: str) -> str: """Copilot's API base comes from the token-exchange response (endpoints.api, with a proxy-ep fallback), which is authoritative for Enterprise / proxied accounts. Falls back to the registry default; the caller's non-empty guard keeps chat inference from ever resolving an empty base URL (#50252).""" base_url = _default_api_key_base_url(api_key, default, env_url) try: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token raw_token, _ = resolve_copilot_token() if raw_token: _, resolved = get_copilot_api_token(raw_token) resolved = (resolved or "").strip() if resolved: base_url = resolved except Exception as exc: logger.debug("Copilot base URL resolution fell back to default: %s", exc) return base_url def _lmstudio_runtime_base_url(api_key: str, default: str, env_url: str) -> str: return _normalize_lmstudio_runtime_base_url(_default_api_key_base_url(api_key, default, env_url)) def _actual_runtime_base_url(api_key: str, default: str, env_url: str) -> str: return normalize_actual_base_url(_default_api_key_base_url(api_key, default, env_url)) # Providers whose runtime base URL is not simply env-override-or-registry-default: # ``(api_key, registry_default, env_override) -> base_url``. _API_KEY_BASE_URL_RESOLVERS: Dict[str, Callable[[str, str, str], str]] = { "kimi-coding": _resolve_kimi_base_url, "kimi-coding-cn": _resolve_kimi_base_url, "zai": _resolve_zai_base_url, "copilot": _copilot_runtime_base_url, "lmstudio": _lmstudio_runtime_base_url, "actual": _actual_runtime_base_url, } def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: """Resolve API key and base URL for an API-key provider.""" pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "api_key": raise AuthError( f"Provider '{provider_id}' is not an API-key provider.", provider=provider_id, code="invalid_provider", ) api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) # No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client # see the local server as configured. doctor still reports unconfigured # because get_api_key_provider_status uses the raw secret resolver. if not api_key and provider_id == "lmstudio": api_key = LMSTUDIO_NOAUTH_PLACEHOLDER key_source = key_source or "default" env_url = _provider_env_base_url(pconfig) base_url = _API_KEY_BASE_URL_RESOLVERS.get(provider_id, _default_api_key_base_url)( api_key, pconfig.inference_base_url, env_url ) # Last-resort guard: an API-key provider must never hand back an empty # base URL (a set-but-empty COPILOT_API_BASE_URL or similar env override # otherwise wedges chat inference — #50252). if not _nonempty_str(base_url): base_url = pconfig.inference_base_url if not api_key and provider_id == "actual" and is_actual_local_base_url(base_url): api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER key_source = key_source or "local-offline" return { "provider": provider_id, "api_key": api_key, "base_url": base_url.rstrip("/"), "source": key_source or "default", } def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]: """Resolve runtime details for local subprocess-backed providers.""" pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "external_process": raise AuthError( f"Provider '{provider_id}' is not an external-process provider.", provider=provider_id, code="invalid_provider", ) command, args, base_url, resolved_command, command_env_vars = _external_process_spec(pconfig) if not resolved_command and not base_url.startswith("acp+tcp://"): _hint = ( " or set " + "/".join(command_env_vars) if command_env_vars else "" ) raise AuthError( f"Could not find the '{provider_id}' CLI command " f"'{command or '(none configured)'}'. Install it{_hint}.", provider=provider_id, code="missing_external_process_cli", ) return { "provider": provider_id, # Placeholder credential: the subprocess owns real auth. Keyed on the # provider id so each external-process provider gets a distinct value. "api_key": pconfig.id or provider_id, "base_url": base_url.rstrip("/"), "command": resolved_command or command, "args": args, "source": "process", } # ============================================================================= # CLI Commands — login / logout # ============================================================================= def _update_config_for_provider( provider_id: str, inference_base_url: str, default_model: Optional[str] = None, ) -> Path: """Update config.yaml and auth.json to reflect the active provider. When *default_model* is provided the function also writes it as the ``model.default`` value. This prevents a race condition where the gateway (which re-reads config per-message) picks up the new provider before the caller has finished model selection, resulting in a mismatched model/provider (e.g. an OpenRouter-style ``vendor/model`` name sent to a direct API). """ # Set active_provider in auth.json so auto-resolution picks this provider with _auth_store_lock(): auth_store = _load_auth_store() auth_store["active_provider"] = provider_id _save_auth_store(auth_store) # Update config.yaml model section config_path = get_config_path() config_path.parent.mkdir(parents=True, exist_ok=True) require_readable_config_before_write(config_path) config = read_raw_config() current_model = config.get("model") if isinstance(current_model, dict): model_cfg = dict(current_model) elif _nonempty_str(current_model): model_cfg = {"default": current_model.strip()} else: model_cfg = {} model_cfg["provider"] = provider_id if inference_base_url and inference_base_url.strip(): model_cfg["base_url"] = inference_base_url.rstrip("/") else: # Clear stale base_url to prevent contamination when switching providers model_cfg.pop("base_url", None) # Clear stale endpoint credentials left over from a previous custom provider. # Built-in providers resolve credentials from env/auth state, not inline # model.api_key. from hermes_cli.config import clear_model_endpoint_credentials clear_model_endpoint_credentials(model_cfg) # When switching to a non-OpenRouter provider, ensure model.default is # valid for the new provider. An OpenRouter-formatted name like # "anthropic/claude-opus-4.6" will fail on direct-API providers. if default_model: cur_default = model_cfg.get("default", "") if not cur_default or "/" in cur_default: model_cfg["default"] = default_model config["model"] = model_cfg atomic_yaml_write(config_path, config, sort_keys=False) return config_path def _get_config_provider() -> Optional[str]: """Return model.provider from config.yaml, normalized, if present.""" try: config = read_raw_config() except Exception: return None if not config: return None model = config.get("model") if not isinstance(model, dict): return None provider = model.get("provider") if not isinstance(provider, str): return None provider = provider.strip().lower() return provider or None def _config_provider_matches(provider_id: Optional[str]) -> bool: """Return True when config.yaml currently selects *provider_id*.""" if not provider_id: return False return _get_config_provider() == provider_id.strip().lower() def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool: """Return True when logout should reset the model provider config.""" if not provider_id: return False normalized = provider_id.strip().lower() return normalized in PROVIDER_REGISTRY and _config_provider_matches(normalized) def _logout_default_provider_from_config() -> Optional[str]: """Fallback logout target when auth.json has no active provider. That left users stuck when auth state had already been cleared but config.yaml still selected an OAuth provider such as openai-codex for the agent model: there was no active auth provider to target, so logout printed "No provider is currently logged in" and never reset model.provider. """ provider = _get_config_provider() flow = OAUTH_PROVIDER_FLOWS.get(provider or "") return provider if flow and flow.logout_from_config else None def _reset_config_provider() -> Path: """Reset config.yaml provider back to auto after logout.""" config_path = get_config_path() if not config_path.exists(): return config_path require_readable_config_before_write(config_path) config = read_raw_config() if not config: return config_path model = config.get("model") if isinstance(model, dict): model["provider"] = "auto" if "base_url" in model: model["base_url"] = OPENROUTER_BASE_URL atomic_yaml_write(config_path, config, sort_keys=False) return config_path def login_command(args) -> None: """Deprecated: use 'hermes model' or 'hermes setup' instead.""" print("The 'hermes login' command has been removed.") print("Use 'hermes auth' to manage credentials,") print("'hermes model' to select a provider, or 'hermes setup' for full setup.") raise SystemExit(0) # ==================== MiniMax Portal OAuth ==================== def get_minimax_oauth_auth_status() -> Dict[str, Any]: """Return auth status dict for MiniMax OAuth provider.""" state = get_provider_auth_state("minimax-oauth") if not state or not state.get("access_token"): return {"logged_in": False, "provider": "minimax-oauth"} try: expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() token_valid = (expires_at - time.time()) > 0 except Exception: token_valid = bool(state.get("access_token")) return { "logged_in": token_valid, "provider": "minimax-oauth", "region": state.get("region", "global"), "expires_at": state.get("expires_at"), } def logout_command(args) -> None: """Clear auth state for a provider.""" provider_id = getattr(args, "provider", None) if provider_id and not is_known_auth_provider(provider_id): print(f"Unknown provider: {provider_id}") raise SystemExit(1) active = get_active_provider() target = provider_id or active or _logout_default_provider_from_config() if not target: print("No provider is currently logged in.") return should_reset_config = _should_reset_config_provider_on_logout(target) provider_name = get_auth_provider_display_name(target) if clear_provider_auth(target) or should_reset_config: if should_reset_config: _reset_config_provider() print(f"Logged out of {provider_name}.") if should_reset_config and os.getenv("OPENROUTER_API_KEY"): print("Hermes will use OpenRouter for inference.") elif should_reset_config: print("Run `hermes model` or configure an API key to use Hermes.") else: print("Model provider configuration was unchanged.") else: print(f"No auth state found for {provider_name}.")