Files
hermes-agent/hermes_cli/auth.py

9602 lines
382 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Multi-provider authentication system for Hermes Agent.
Architecture: - ProviderConfig registry defines known OAuth providers - Auth store (auth.json) holds
per-provider credential state - resolve_provider() picks the active provider via priority chain -
resolve_*_runtime_credentials() handles token refresh and runtime keys - logout_command() is the CLI
entry point for clearing auth
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import shlex
import ssl
import stat
import sys
import base64
import hashlib
import subprocess
import threading
import time
import uuid
import webbrowser
# httpx is imported lazily: it costs ~30ms at import time and hermes_cli.auth
# is on the interactive-CLI startup path via credential_pool → auxiliary_client
# → cli_commands_mixin, where no HTTP request is ever made before first use.
# The proxy resolves to the real module on first attribute access; every
# consumer in this file uses `httpx.<attr>` so the swap is transparent.
# Annotations like ``httpx.Client`` stay valid: `from __future__ import
# annotations` (above) keeps them unevaluated at runtime, and the
# TYPE_CHECKING import gives static checkers the real module.
import importlib as _importlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import httpx
else:
class _LazyHttpx:
__slots__ = ("_mod",)
def __init__(self) -> None:
object.__setattr__(self, "_mod", None)
def _resolve(self):
mod = object.__getattribute__(self, "_mod")
if mod is None:
mod = _importlib.import_module("httpx")
object.__setattr__(self, "_mod", mod)
return mod
def __getattr__(self, name):
return getattr(self._resolve(), name)
# Forward set/del to the real module so monkeypatch.setattr
# ("hermes_cli.auth.httpx.Client", ...) keeps working in tests.
def __setattr__(self, name, value):
setattr(self._resolve(), name, value)
def __delattr__(self, name):
delattr(self._resolve(), name)
httpx = _LazyHttpx()
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple
from urllib.parse import parse_qs, urlencode, 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
logger = logging.getLogger(__name__)
try:
import fcntl
except Exception:
fcntl = None
try:
import msvcrt
except Exception:
msvcrt = None
# =============================================================================
# Constants
# =============================================================================
AUTH_STORE_VERSION = 1
AUTH_LOCK_TIMEOUT_SECONDS = 15.0
# Nous Portal defaults
DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke"
NOUS_BILLING_MANAGE_SCOPE = "billing:manage"
DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE
NOUS_DEVICE_CODE_SOURCE = "device_code"
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
_FORM_JSON_HEADERS = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
DEFAULT_XAI_OAUTH_BASE_URL = "https://api.x.ai/v1"
MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1"
DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1"
STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
try: # Version tag for the Codex token-endpoint User-Agent; fall back if unavailable.
from hermes_cli import __version__ as _HERMES_CLI_VERSION
except Exception: # pragma: no cover - version import should always succeed
_HERMES_CLI_VERSION = "unknown"
CODEX_OAUTH_USER_AGENT = f"hermes-cli/{_HERMES_CLI_VERSION}"
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"
XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code"
# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in
# current SuperGrok flows). A two-minute refresh window is too narrow for
# gateway/cron workloads that may only touch the provider every 30 minutes,
# leaving brief but noisy credential-expiry gaps. Refresh up to one hour
# early so ordinary runtime calls keep the token warm without user reauth.
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL = "https://accounts.spotify.com"
DEFAULT_SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1"
DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback"
SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify"
SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard"
SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh"
DEFAULT_SPOTIFY_SCOPE = " ".join((
"user-modify-playback-state",
"user-read-playback-state",
"user-read-currently-playing",
"user-read-recently-played",
"playlist-read-private",
"playlist-read-collaborative",
"playlist-modify-public",
"playlist-modify-private",
"user-library-read",
"user-library-modify",
))
SERVICE_PROVIDER_NAMES: Dict[str, str] = {
"spotify": "Spotify",
}
# LM Studio's default no-auth mode still requires *some* non-empty bearer for
# the API-key code paths (auxiliary_client, runtime resolver) to treat the
# provider as configured. This sentinel is sent only to LM Studio, never to
# any remote service.
LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key"
ACTUAL_LOCAL_NOAUTH_PLACEHOLDER = "dummy-actual-local-api-key"
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",
),
}
# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
# providers/ that is not already declared above. New providers only need a
# plugins/model-providers/<name>/ plugin — no edits to this file required.
try:
from providers import list_providers as _list_providers_for_registry
for _pp in _list_providers_for_registry():
if _pp.name in PROVIDER_REGISTRY:
continue
if _pp.auth_type == "external_process":
# An external-process provider (an ACP CLI driven over stdio) has no
# API-key env vars to resolve — its credentials come from
# resolve_external_process_provider_credentials(), keyed on this
# auth_type. Registering it here is what lets a provider shipped
# outside this tree pass resolve_provider()'s known-provider gate;
# without it, `hermes -m <that provider>` dies with
# "Unknown provider" before any client is ever built.
PROVIDER_REGISTRY[_pp.name] = ProviderConfig(
id=_pp.name,
name=_pp.display_name or _pp.name,
auth_type="external_process",
inference_base_url=_pp.base_url,
)
for _alias in _pp.aliases:
if _alias not in PROVIDER_REGISTRY:
PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
continue
if _pp.auth_type != "api_key" or not _pp.env_vars:
continue
# Skip providers that need custom token resolution or are special-cased
# in resolve_provider() (copilot/kimi/zai have bespoke token refresh;
# openrouter/custom are aggregator/user-supplied and handled outside
# the registry — adding them here breaks runtime_provider resolution
# that relies on `openrouter not in PROVIDER_REGISTRY`).
if _pp.name in {"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}:
continue
_api_key_vars = tuple(v for v in _pp.env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL"))
_base_url_var = next((v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), None)
PROVIDER_REGISTRY[_pp.name] = 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=_api_key_vars or _pp.env_vars,
base_url_env_var=_base_url_var or "",
)
# Also register aliases so resolve_provider() resolves them
for _alias in _pp.aliases:
if _alias not in PROVIDER_REGISTRY:
PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
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 ""
# =============================================================================
# Kimi Code Endpoint Detection
# =============================================================================
# Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work
# on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on
# api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set
# KIMI_BASE_URL explicitly.
#
# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint
# speaks the Anthropic Messages protocol, and the anthropic SDK appends
# "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages"
# (the correct target). Using "/coding/v1" here would produce
# "/coding/v1/v1/messages" (a 404).
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding"
def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:
"""Return the correct Kimi base URL based on the API key prefix.
If the user has explicitly set KIMI_BASE_URL, that always wins. Otherwise, sk-kimi- prefixed
keys route to api.kimi.com/coding/v1.
"""
if env_override:
return env_override
# No key → nothing to infer from. Return default without inspecting.
if not api_key:
return default_url
if api_key.startswith("sk-kimi-"):
return KIMI_CODE_BASE_URL
return default_url
_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 "", ""
# =============================================================================
# Z.AI Endpoint Detection
# =============================================================================
# Z.AI has separate billing for general vs coding plans, and global vs China
# endpoints. A key that works on one may return "Insufficient balance" on
# another. We probe at setup time and store the working endpoint.
# Each entry lists candidate models to try in order — newer coding plan accounts
# may only have access to recent models (glm-5.1, glm-5v-turbo) while older
# ones still use glm-4.7.
ZAI_ENDPOINTS = [
# (id, base_url, probe_models, label)
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
]
def _probe_single_zai_endpoint(
api_key: str, endpoint: tuple, timeout: float,
) -> Optional[Dict[str, str]]:
"""Probe a single Z.AI endpoint. Returns endpoint info dict or None.
Preserves the per-endpoint candidate-model loop: endpoints carry a ``probe_models`` LIST and
each model is tried in order until one succeeds (some plans only accept newer/older GLM slugs).
"""
ep_id, base_url, probe_models, label = endpoint
for model in probe_models:
try:
resp = httpx.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"stream": False,
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
},
timeout=timeout,
)
if resp.status_code == 200:
logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
return {
"id": ep_id,
"base_url": base_url,
"model": model,
"label": label,
}
logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
except Exception as exc:
logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
return None
def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:
"""Probe z.ai endpoints in parallel to find one that accepts this API key.
Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the first working endpoint
(in ZAI_ENDPOINTS priority order), or None if all fail. For endpoints with multiple candidate
models, each worker tries its endpoint's models in order and returns the first that succeeds.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
# No `with` block: a context manager would join ALL probe threads on
# exit, defeating the early return below. shutdown(wait=False) lets the
# surviving daemon-style probes drain in the background instead of
# blocking the caller on slow/unreachable endpoints.
pool = ThreadPoolExecutor(max_workers=len(ZAI_ENDPOINTS))
try:
futures = {
pool.submit(_probe_single_zai_endpoint, api_key, ep, timeout): ep[0]
for ep in ZAI_ENDPOINTS
}
by_id = {ep_id: f for f, ep_id in futures.items()}
results: Dict[str, Dict[str, str]] = {}
for future in as_completed(futures):
ep_id = futures[future]
try:
result = future.result()
if result is not None:
results[ep_id] = result
except Exception:
pass
# Early exit in PRIORITY order: walk endpoints highest-priority
# first; if one has succeeded and every higher-priority probe
# has already finished (without success), no later completion
# can win — return now instead of waiting out slow endpoints
# (main's sequential loop also stopped at first success).
for ep in ZAI_ENDPOINTS:
if not by_id[ep[0]].done():
break # a higher-priority probe is still in flight
if ep[0] in results:
return results[ep[0]]
# All probes finished: first match in priority order, if any.
for ep in ZAI_ENDPOINTS:
if ep[0] in results:
return results[ep[0]]
return None
finally:
pool.shutdown(wait=False)
def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:
"""Return the correct Z.AI base URL by probing endpoints.
If the user has explicitly set GLM_BASE_URL, that always wins. Otherwise, probe the candidate
endpoints to find one that accepts the key. The detected endpoint is cached in provider state
(auth.json) keyed on a hash of the API key so subsequent starts skip the probe.
"""
if env_override:
return env_override
# No API key set → don't probe (would fire N×M HTTPS requests with an
# empty Bearer token, all returning 401). This path is hit during
# auxiliary-client auto-detection when the user has no Z.AI credentials
# at all — the caller discards the result immediately, so the probe is
# pure latency for every AIAgent construction.
if not api_key:
return default_url
# Check provider-state cache for a previously-detected endpoint.
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "zai") or {}
cached = state.get("detected_endpoint")
if isinstance(cached, dict) and cached.get("base_url"):
key_hash = cached.get("key_hash", "")
if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:
logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
return cached["base_url"]
# Probe — may take up to ~8s per endpoint.
detected = detect_zai_endpoint(api_key)
if detected and detected.get("base_url"):
# Persist the detection result keyed on the API key hash.
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
detected_endpoint = {
"base_url": detected["base_url"],
"endpoint_id": detected.get("id", ""),
"model": detected.get("model", ""),
"label": detected.get("label", ""),
"key_hash": key_hash,
}
# Persist failure (disk full, permissions, lock timeout) must not
# break resolution — detection already succeeded; worst case the
# next start re-probes.
try:
with _auth_store_lock():
# Reload auth_store under lock to avoid overwriting concurrent changes
auth_store = _load_auth_store()
state_under_lock = _load_provider_state(auth_store, "zai") or {}
state_under_lock["detected_endpoint"] = detected_endpoint
# set_active=False: this runs from credential-pool env seeding
# (agent/credential_pool.py) for ANY user with a Z.AI key in env,
# and caching a probe result must not flip their active provider.
_store_provider_state(auth_store, "zai", state_under_lock, set_active=False)
_save_auth_store(auth_store)
except Exception as exc:
logger.warning("Z.AI: could not persist detected endpoint (%s); will re-probe next start", exc)
logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
return detected["base_url"]
logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
return default_url
def _normalize_lmstudio_runtime_base_url(base_url: str) -> str:
"""Return the OpenAI-compatible LM Studio runtime base URL.
LM Studio's native management API lives under ``/api/v1`` while its OpenAI-compatible chat
endpoint lives under ``/v1``. Users often paste either form into ``LM_BASE_URL`` or
``model.base_url``; normalize before the OpenAI SDK appends ``/chat/completions``.
"""
root = str(base_url or "").strip().rstrip("/")
for suffix in ("/api/v1", "/api", "/v1"):
if root.endswith(suffix):
root = root[: -len(suffix)].rstrip("/")
break
return (root or "http://127.0.0.1:1234") + "/v1"
# =============================================================================
# Error Types
# =============================================================================
# Error code marking upstream rate-limit / usage-quota exhaustion (HTTP 429).
# Such failures are transient and re-authenticating cannot resolve them, so
# they must be kept distinct from missing/expired-credential errors.
CODEX_RATE_LIMITED_CODE = "codex_rate_limited"
class AuthError(RuntimeError):
"""Structured auth error with UX mapping hints."""
def __init__(
self,
message: str,
*,
provider: str = "",
code: Optional[str] = None,
relogin_required: bool = False,
) -> None:
super().__init__(message)
self.provider = provider
self.code = code
self.relogin_required = relogin_required
def _provider_error_factory(provider: str) -> Callable[..., AuthError]:
def factory(message: str, code: Optional[str] = None, *, relogin: bool = False) -> AuthError:
return AuthError(message, provider=provider, code=code, relogin_required=relogin)
return factory
# Per-provider AuthError constructors: ``_xai_err(message, code, relogin=True)``.
_nous_err = _provider_error_factory("nous")
_xai_err = _provider_error_factory("xai-oauth")
_codex_err = _provider_error_factory("openai-codex")
_spotify_err = _provider_error_factory("spotify")
_qwen_err = _provider_error_factory("qwen-oauth")
_minimax_err = _provider_error_factory("minimax-oauth")
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 _parse_retry_after_seconds(headers: Any) -> Optional[int]:
"""Best-effort parse of a ``Retry-After`` header into whole seconds."""
from agent.retry_utils import parse_retry_after_seconds
seconds = parse_retry_after_seconds(headers)
return None if seconds is None else int(seconds)
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 _format_nous_entitlement_auth_error(error: AuthError) -> str:
try:
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
account_info = get_nous_portal_account_info(force_fresh=True)
message = format_nous_portal_entitlement_message(
account_info,
capability="Nous model access",
)
if message:
return message
except Exception:
pass
return f"{error} Check credits or billing in Nous Portal, then retry."
def _nonempty_str(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def _token_fingerprint(token: Any) -> Optional[str]:
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
if not isinstance(token, str):
return None
cleaned = token.strip()
if not cleaned:
return None
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12]
def _oauth_trace_enabled() -> bool:
raw = os.getenv("HERMES_OAUTH_TRACE", "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:
if not _oauth_trace_enabled():
return
payload: Dict[str, Any] = {"event": event}
if sequence_id:
payload["sequence_id"] = sequence_id
payload.update(fields)
logger.info("oauth_trace %s", json.dumps(payload, sort_keys=True, ensure_ascii=False))
# =============================================================================
# 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.
"""
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")
preserved = False
try:
import shutil
shutil.copy2(auth_file, corrupt_path)
preserved = True
except Exception:
logger.debug(
"auth: could not preserve a copy of the corrupt store at %s",
corrupt_path, exc_info=True,
)
if preserved:
logger.warning(
"auth: failed to parse %s (%s), starting with empty store. "
"Corrupt file preserved at %s",
auth_file, exc, corrupt_path,
)
else:
# Do not advertise a backup that was never written.
logger.warning(
"auth: failed to parse %s (%s), starting with empty store. "
"A copy could NOT be preserved at %s",
auth_file, exc, 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 _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:
providers = auth_store.setdefault("providers", {})
if not isinstance(providers, dict):
auth_store["providers"] = {}
providers = auth_store["providers"]
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
# Pool providers whose OAuth refresh tokens are SINGLE-USE: redeeming the
# refresh token rotates the pair and revokes the old one. A grant forked into
# two auth.json files is therefore not two credentials but one credential with
# two owners — the first owner to refresh strands the other with
# ``invalid_grant`` / ``refresh_token_reused`` (#100339; same class as the
# ``providers.<id>`` write-through hazard in #48415 / #43589). Profiles must
# never receive a copy of these grants: ONE grant lives at the global root and
# named profiles read it through the ``read_credential_pool`` root fallback.
SINGLE_USE_REFRESH_POOL_PROVIDERS = frozenset({
"anthropic",
"openai-codex",
"xai-oauth",
})
# Singleton credential files that hold the same single-use grants outside
# ``auth.json``. Copying one into a profile re-seeds a forked pool row on the
# profile's next ``load_pool()``.
SINGLE_USE_OAUTH_SINGLETON_FILES = (".anthropic_oauth.json",)
def _is_oauth_pool_payload(entry: Any) -> bool:
if not isinstance(entry, dict):
return False
auth_type = str(entry.get("auth_type") or "").strip().lower()
if auth_type == "oauth":
return True
# Legacy rows predating ``auth_type``: an Anthropic OAuth access token or
# any row carrying a refresh token is an OAuth grant.
if str(entry.get("refresh_token") or "").strip():
return True
return str(entry.get("access_token") or "").startswith("sk-ant-oat")
def strip_cloned_single_use_oauth_grants(profile_dir: Path) -> Dict[str, Any]:
"""Remove forked single-use OAuth grants from a freshly cloned profile.
Called after any code path that copies credential files from one profile into another (``hermes
profile create --clone-all``, the dashboard/TUI ``mirror_credentials`` flow). API-key pool rows
are kept — a static key is safe to duplicate.
Returns a summary ``{"pool": [...provider ids], "providers": [...], "files": [...]}`` of what
was stripped (empty lists when nothing was). Never raises: a clone must not fail because
credential hygiene could not run — the caller logs the summary.
"""
stripped: Dict[str, Any] = {"pool": [], "providers": [], "files": []}
profile_dir = Path(profile_dir)
for name in SINGLE_USE_OAUTH_SINGLETON_FILES:
try:
target = profile_dir / name
if target.is_file() or target.is_symlink():
target.unlink()
stripped["files"].append(name)
except OSError:
logger.debug("Could not remove cloned %s from %s", name, profile_dir, exc_info=True)
auth_path = profile_dir / "auth.json"
if not auth_path.is_file():
return stripped
try:
store = json.loads(auth_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return stripped
if not isinstance(store, dict):
return stripped
changed = False
pool = store.get("credential_pool")
if isinstance(pool, dict):
for provider_id in list(pool):
if provider_id not in SINGLE_USE_REFRESH_POOL_PROVIDERS:
continue
entries = pool.get(provider_id)
if not isinstance(entries, list):
continue
kept = [e for e in entries if not _is_oauth_pool_payload(e)]
if len(kept) != len(entries):
changed = True
stripped["pool"].append(provider_id)
if kept:
pool[provider_id] = kept
else:
# No local rows at all → read_credential_pool falls back
# to the root slice for this provider.
del pool[provider_id]
providers = store.get("providers")
if isinstance(providers, dict):
# Device-code grants for these providers live under providers.<id>;
# _load_provider_state has the same root fallback, so dropping the
# copy keeps the profile working while removing the fork.
for provider_id in ("openai-codex", "xai-oauth"):
block = providers.get(provider_id)
if isinstance(block, dict) and block:
del providers[provider_id]
stripped["providers"].append(provider_id)
changed = True
if not changed:
return stripped
try:
_save_auth_store(store, target_path=auth_path)
except Exception:
logger.debug(
"Failed to strip cloned single-use OAuth grants from %s",
auth_path,
exc_info=True,
)
return stripped
# ── One-time heal for installs that ALREADY forked a single-use grant ────────
#
# Fleets created before the clone-strip / root-write-through above have
# profile-local copies of the root grant. Those copies are the same credential
# with several owners: whichever profile rotated last holds the only live
# refresh token and every other copy (root included) is spent. Upgrading alone
# does not fix that — the first load in each profile would keep using its own
# doomed copy. ``heal_forked_single_use_oauth_grants`` runs at profile
# ``load_pool()`` time: it finds the profile rows that share LINEAGE with a
# root row (same pool id — clone-all and the old borrowed-persist both kept
# it — or the same account identity / token material), keeps the copy most
# likely to still be live (freshest rotation), writes that copy into ROOT when
# root's is older, and strips the profile's copy so the profile borrows root
# from then on. Idempotent (a healed profile has no matched rows), never
# touches API-key rows, never deletes a row that has no root counterpart
# (an independent ``hermes -p <p> auth add`` grant, or the only surviving
# copy), and reads only the two auth.json files the existing root fallback
# already reads — no environ / secret-scope reads.
_OAUTH_TOKEN_FIELDS = (
"access_token",
"refresh_token",
"expires_at",
"expires_at_ms",
"last_refresh",
)
_oauth_heal_notices: List[str] = []
# provider -> (profile auth.json path, auth.json mtime_ns, singleton mtime_ns)
# of the last store verified fork-free; lets load_pool() skip the locked scan.
_oauth_heal_clean_marks: Dict[str, Tuple[str, Optional[int], Optional[int]]] = {}
def consume_oauth_heal_notices() -> List[str]:
"""Return (and clear) human-readable notes about heals run in this process.
``hermes auth list`` / ``hermes auth status`` print them so the user sees that a forked grant
was consolidated rather than only finding it in logs.
"""
notes = list(_oauth_heal_notices)
_oauth_heal_notices.clear()
return notes
def _oauth_identity(entry: Dict[str, Any]) -> Optional[str]:
"""Stable account identity for an OAuth row when the token carries one.
Codex / xAI access tokens are JWTs with ``sub`` / ``email`` / ``chatgpt_account_id`` claims;
Anthropic ``sk-ant-oat`` tokens carry none (returns None, so lineage rests on id / token
material).
"""
if not isinstance(entry, dict):
return None
for token in (entry.get("access_token"), entry.get("id_token")):
claims = _decode_jwt_claims(token)
if not claims:
continue
nested = claims.get("https://api.openai.com/auth")
account = nested.get("chatgpt_account_id") if isinstance(nested, dict) else None
for value in (account, claims.get("sub"), claims.get("email")):
if _nonempty_str(value):
return value.strip()
return None
def _oauth_freshness(entry: Dict[str, Any]) -> float:
"""Best-effort 'how recently was this pair issued' score (epoch seconds).
A rotation always issues a later-expiring access token, so ``expires_at`` ordering identifies
the live copy; ``last_refresh`` and the JWT ``exp`` claim are fallbacks for rows that do not
persist expiry.
"""
from agent.credential_pool import _parse_absolute_timestamp
best = 0.0
for key in ("expires_at_ms", "expires_at", "last_refresh"):
ts = _parse_absolute_timestamp(entry.get(key))
if ts and ts > best:
best = ts
if best == 0.0:
exp = _decode_jwt_claims(entry.get("access_token")).get("exp")
ts = _parse_absolute_timestamp(exp)
if ts:
best = ts
return best
def _find_root_counterpart(
profile_row: Dict[str, Any], root_rows: List[Dict[str, Any]]
) -> Optional[int]:
"""Index of the root OAuth row that shares a grant lineage with *profile_row*.
Fallback per the one-grant-at-root rule: same provider + same OAuth client — every Anthropic
``hermes_pkce`` grant uses one client id and carries no claims, so two Anthropic OAuth rows with
no contrary identity are one lineage.
"""
candidates = [i for i, r in enumerate(root_rows) if _is_oauth_pool_payload(r)]
if not candidates:
return None
pid = profile_row.get("id")
for i in candidates:
if pid and root_rows[i].get("id") == pid:
return i
p_ident = _oauth_identity(profile_row)
for i in candidates:
r_ident = _oauth_identity(root_rows[i])
if p_ident and r_ident and p_ident == r_ident:
return i
for key in ("refresh_token", "access_token"):
p_val = profile_row.get(key)
if not _nonempty_str(p_val):
continue
for i in candidates:
if root_rows[i].get(key) == p_val:
return i
# Fallback: same provider + same client. Only a contradicting identity
# (both sides carry claims and they differ from every root row) blocks it.
if p_ident:
for i in candidates:
if not _oauth_identity(root_rows[i]):
return i
return None
return candidates[0]
def _adopt_oauth_material(target: Dict[str, Any], winner: Dict[str, Any]) -> Dict[str, Any]:
"""Return *target* carrying *winner*'s token pair, status markers cleared."""
merged = dict(target)
for key in _OAUTH_TOKEN_FIELDS:
if winner.get(key) is not None:
merged[key] = winner[key]
else:
merged.pop(key, None)
for status_field in _POOL_STATUS_FIELDS:
merged[status_field] = None
return merged
def _singleton_as_row(path: Path) -> Optional[Dict[str, Any]]:
"""Read a ``.anthropic_oauth.json`` as a pool-row-shaped dict, or None."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not str(data.get("accessToken") or "").strip():
return None
return {
"access_token": data.get("accessToken"),
"refresh_token": data.get("refreshToken"),
"expires_at_ms": data.get("expiresAt"),
}
def heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, Any]]:
"""Consolidate a profile's forked copy of a single-use OAuth grant into root.
Runs only in profile mode for ``SINGLE_USE_REFRESH_POOL_PROVIDERS``. Returns a summary
``{"adopted": bool, "stripped_ids": [...], "files": [...], "providers_block": bool}`` when
something was healed, else ``None``. Never raises.
"""
if provider_id not in SINGLE_USE_REFRESH_POOL_PROVIDERS:
return None
try:
return _heal_forked_single_use_oauth_grants(provider_id)
except Exception:
logger.debug("%s: forked-OAuth heal skipped", provider_id, exc_info=True)
return None
def _heal_forked_provider_block(
profile_store: Dict[str, Any], root_store: Dict[str, Any], provider_id: str,
) -> Optional[bool]:
"""Consolidate a forked ``providers.<id>`` device-code block into root.
Returns None when nothing matched, False when the profile copy was dropped (root already
newest), True when the profile copy was fresher and was adopted into root.
"""
p_providers = profile_store.get("providers")
r_providers = root_store.get("providers")
if not (isinstance(p_providers, dict) and isinstance(r_providers, dict)):
return None
p_block = p_providers.get(provider_id)
r_block = r_providers.get(provider_id)
if not (isinstance(p_block, dict) and p_block and isinstance(r_block, dict) and r_block):
return None
def _flat(block: Dict[str, Any]) -> Dict[str, Any]:
tokens = block.get("tokens") if isinstance(block.get("tokens"), dict) else {}
return {**tokens, "last_refresh": block.get("last_refresh")}
p_flat, r_flat = _flat(p_block), _flat(r_block)
p_ident, r_ident = _oauth_identity(p_flat), _oauth_identity(r_flat)
if p_ident and r_ident and p_ident != r_ident:
return None
adopted = _oauth_freshness(p_flat) > _oauth_freshness(r_flat)
if adopted:
r_providers[provider_id] = dict(p_block)
del p_providers[provider_id]
return adopted
def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, Any]]:
root_path = _global_auth_file_path()
if root_path is None:
return None # classic mode: nothing to consolidate into
if os.environ.get("PYTEST_CURRENT_TEST"):
# Same seat belt as the write-through paths: never touch the real
# user's ~/.hermes/auth.json from a test that forgot to isolate HOME.
real_home_env = os.environ.get("HOME", "")
if real_home_env and _same_path(root_path, Path(real_home_env) / ".hermes" / "auth.json"):
return None
profile_path = _auth_file_path()
profile_home = profile_path.parent
root_home = root_path.parent
profile_singleton = profile_home / ".anthropic_oauth.json" if provider_id == "anthropic" else None
# Hot-path short-circuit: load_pool() runs per model call. Once this
# profile's store was verified clean for *provider_id*, skip the locked
# read-modify-write until the profile's own files change (mtime key).
def _stamp(p: Optional[Path]) -> Optional[int]:
try:
return p.stat().st_mtime_ns if p is not None else None
except OSError:
return None
fingerprint = (str(profile_path), _stamp(profile_path), _stamp(profile_singleton))
if _oauth_heal_clean_marks.get(provider_id) == fingerprint:
return None
if fingerprint[1] is None and fingerprint[2] is None:
_oauth_heal_clean_marks[provider_id] = fingerprint
return None
summary: Dict[str, Any] = {"adopted": False, "stripped_ids": [], "files": [], "providers_block": False}
log_bits: List[str] = []
# Lock order: active (profile) store first, then the root source store —
# the same order ``_provider_state_transaction`` uses.
with _auth_store_lock():
profile_store = _load_auth_store(profile_path) if profile_path.exists() else {"providers": {}}
with _auth_store_lock(target_path=root_path):
root_store = _load_auth_store(root_path) if root_path.exists() else {"providers": {}}
profile_changed = False
root_changed = False
p_pool = profile_store.get("credential_pool")
p_rows = p_pool.get(provider_id) if isinstance(p_pool, dict) else None
p_rows = p_rows if isinstance(p_rows, list) else []
r_pool = root_store.get("credential_pool")
r_rows = r_pool.get(provider_id) if isinstance(r_pool, dict) else None
r_rows = r_rows if isinstance(r_rows, list) else []
r_oauth = [r for r in r_rows if _is_oauth_pool_payload(r)]
root_singleton = root_home / ".anthropic_oauth.json" if provider_id == "anthropic" else None
root_singleton_row = (
_singleton_as_row(root_singleton)
if root_singleton is not None and root_singleton.exists() else None
)
# ── credential_pool rows ────────────────────────────────────
kept_rows: List[Any] = []
for row in p_rows:
if not _is_oauth_pool_payload(row):
kept_rows.append(row) # API keys are safe to duplicate
continue
match_idx = _find_root_counterpart(row, r_rows)
if match_idx is not None:
root_row = r_rows[match_idx]
if _oauth_freshness(row) > _oauth_freshness(root_row):
r_rows[match_idx] = _adopt_oauth_material(root_row, row)
root_changed = True
summary["adopted"] = True
summary["stripped_ids"].append(row.get("id"))
profile_changed = True
continue
# No root pool counterpart. Root's grant may live only in its
# .anthropic_oauth.json (the ``hermes auth`` PKCE shape); a
# profile hermes_pkce-family row is that grant's copy.
is_pkce = str(row.get("source") or "").endswith("hermes_pkce")
if is_pkce and root_singleton_row is not None and not r_oauth:
if _oauth_freshness(row) > _oauth_freshness(root_singleton_row):
root_singleton_row = _adopt_oauth_material(root_singleton_row, row)
summary["adopted"] = True
summary["stripped_ids"].append(row.get("id"))
profile_changed = True
continue
# Root holds no copy of this lineage (independent account, or
# root never had the grant): the profile's row may be the
# only surviving copy — leave it alone.
kept_rows.append(row)
if profile_changed and isinstance(p_pool, dict):
if kept_rows:
p_pool[provider_id] = kept_rows
else:
p_pool.pop(provider_id, None)
# ── providers.<id> device-code blocks (Codex / xAI) ─────────
if provider_id in ("openai-codex", "xai-oauth"):
block_result = _heal_forked_provider_block(profile_store, root_store, provider_id)
if block_result is not None:
profile_changed = True
summary["providers_block"] = True
if block_result:
root_changed = True
summary["adopted"] = True
# ── profile-local .anthropic_oauth.json singleton ───────────
if profile_singleton is not None and profile_singleton.exists():
p_single = _singleton_as_row(profile_singleton)
root_has_grant = bool(r_oauth) or root_singleton_row is not None
if p_single is not None and root_has_grant:
if root_singleton_row is not None:
if _oauth_freshness(p_single) > _oauth_freshness(root_singleton_row):
root_singleton_row = _adopt_oauth_material(root_singleton_row, p_single)
summary["adopted"] = True
else:
# Root only has pool rows: fold the singleton's pair
# into the freshest-matching root pkce row, if any.
idx = next(
(i for i, r in enumerate(r_rows)
if _is_oauth_pool_payload(r)
and str(r.get("source") or "").endswith("hermes_pkce")),
None,
)
if idx is not None and _oauth_freshness(p_single) > _oauth_freshness(r_rows[idx]):
r_rows[idx] = _adopt_oauth_material(r_rows[idx], p_single)
root_changed = True
summary["adopted"] = True
try:
profile_singleton.unlink()
summary["files"].append(profile_singleton.name)
except OSError:
logger.debug("could not remove %s", profile_singleton, exc_info=True)
# Otherwise root has NO grant for this provider (or the file
# is not a grant): the profile's singleton may be the only
# surviving copy — never delete it.
if not (profile_changed or root_changed or summary["adopted"]):
_oauth_heal_clean_marks[provider_id] = fingerprint
return None
if summary["adopted"] and root_singleton is not None and root_singleton_row is not None:
# Keep root's singleton and its ``hermes_pkce``-seeded pool row
# in step: root's next load_pool() re-seeds that row FROM the
# singleton file, so a stale file would resurrect the spent
# pair (and a stale row would be overwritten by a fresh file).
pkce_idx = next(
(i for i, r in enumerate(r_rows)
if _is_oauth_pool_payload(r) and r.get("source") == "hermes_pkce"),
None,
)
if pkce_idx is not None:
pkce_row = r_rows[pkce_idx]
if _oauth_freshness(pkce_row) > _oauth_freshness(root_singleton_row):
root_singleton_row = _adopt_oauth_material(root_singleton_row, pkce_row)
elif _oauth_freshness(root_singleton_row) > _oauth_freshness(pkce_row):
r_rows[pkce_idx] = _adopt_oauth_material(pkce_row, root_singleton_row)
root_changed = True
if root_changed:
if isinstance(r_pool, dict):
r_pool[provider_id] = r_rows
else:
root_store["credential_pool"] = {provider_id: r_rows}
_save_auth_store(root_store, target_path=root_path)
if summary["adopted"] and root_singleton is not None and root_singleton_row is not None:
from agent.anthropic_credentials import _write_hermes_oauth_credentials
_write_hermes_oauth_credentials(
root_singleton_row.get("access_token") or "",
root_singleton_row.get("refresh_token"),
root_singleton_row.get("expires_at_ms"),
target=root_singleton,
)
if profile_changed and profile_path.exists():
_save_auth_store(profile_store, target_path=profile_path)
if summary["stripped_ids"]:
log_bits.append(f"pool rows {summary['stripped_ids']}")
if summary["providers_block"]:
log_bits.append(f"providers.{provider_id} block")
if summary["files"]:
log_bits.append(", ".join(summary["files"]))
verdict = (
"profile copy was the live pair; root updated"
if summary["adopted"] else "root copy already newest; profile copy dropped"
)
message = (
f"profile {profile_home.name}: consolidated forked {provider_id} OAuth grant "
f"({'; '.join(log_bits) or 'no-op'}) into the root grant — {verdict}; "
f"this profile now borrows the root grant (#100339)"
)
logger.info(message)
_oauth_heal_notices.append(message)
return summary
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 <provider>`` 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 _clear_pool_entry_status(entry: Dict[str, Any]) -> None:
"""Reset a pool entry's cooldown / last-error metadata to healthy."""
for status_field in _POOL_STATUS_FIELDS:
entry[status_field] = None
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 = auth_store.get("credential_pool")
if not isinstance(pool, dict):
pool = {}
auth_store["credential_pool"] = 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 = auth_store.get("suppressed_sources")
if not isinstance(suppressed, dict):
suppressed = {}
auth_store["suppressed_sources"] = suppressed
provider_list = _suppressed_source_list(suppressed, provider_id)
if provider_list is None:
provider_list = []
suppressed[provider_id] = provider_list
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 is_provider_explicitly_configured(provider_id: str) -> bool:
"""Return True only if the user has explicitly configured this provider.
Claude Code's ~/.claude/.credentials.json) so they are never used without the user's explicit
choice.
"""
normalized = (provider_id or "").strip().lower()
# 1. Check auth.json active_provider
try:
auth_store = _load_auth_store()
active = (auth_store.get("active_provider") or "").strip().lower()
if active and active == normalized:
return True
except Exception:
pass
# 2. Check config.yaml model.provider and other explicit provider slots.
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
cfg_provider = (model_cfg.get("provider") or "").strip().lower()
if cfg_provider == normalized:
return True
# MoA presets are explicit model selections too. A user who configured
# ``provider: anthropic`` as a MoA advisor/aggregator has opted Hermes
# into using Anthropic credentials for that slot even when the main
# session model is another provider. Without this, Claude Code OAuth
# entries are pruned/ignored by credential_pool.load_pool("anthropic"),
# so MoA Anthropic advisors fail with "no ANTHROPIC_API_KEY" while the
# normal model picker says Anthropic is logged in.
def _slot_matches_provider(slot):
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_provider(s) for s in block.get("reference_models") or [])
or _slot_matches_provider(block.get("aggregator"))
)
moa_cfg = cfg.get("moa")
if isinstance(moa_cfg, dict):
if _moa_block_matches(moa_cfg):
return True
presets = moa_cfg.get("presets")
if isinstance(presets, dict) and any(_moa_block_matches(p) for p in presets.values()):
return True
except Exception:
pass
# 3. Provider-specific env vars (explicit secrets only).
if _explicit_env_credentials_present(normalized):
return True
# 4. Check persisted credential-pool entries that came from EXPLICIT flows
# the user initiated inside Hermes (manual add / device-code / PKCE), plus
# env-backed pool entries. This intentionally excludes ambient borrowed
# sources like gh_cli / claude_code / qwen-cli.
try:
if any(_pool_entry_is_explicit(entry) for entry in read_credential_pool(normalized)):
return True
except Exception:
pass
# 5. OAuth-token / cloud-SDK providers (Vertex AI, Bedrock) have NO API-key
# env var to detect in step 3 and mint short-lived tokens from ADC / a
# service account / the AWS SDK chain. The user "explicitly configures"
# them by writing non-secret routing settings into config.yaml
# (``vertex.project_id`` / a credentials path, ``bedrock.region``) rather
# than by pasting a key — so without this branch such a provider is only
# ever "explicitly configured" while it is the *current* provider, and it
# silently vanishes from explicit-only pickers (desktop chat model menu)
# otherwise. Treat the presence of that deliberate config as explicit.
try:
if _keyless_provider_has_explicit_config(normalized):
return True
except Exception as exc:
logger.debug("Failed checking keyless provider explicit config for %s: %s", provider_id, exc)
return False
# 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
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
providers = auth_store.get("providers", {})
if not isinstance(providers, dict):
providers = {}
auth_store["providers"] = providers
pool = auth_store.get("credential_pool")
if not isinstance(pool, dict):
pool = {}
auth_store["credential_pool"] = pool
cleared = False
if target in providers:
del providers[target]
cleared = True
if target in pool:
del pool[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.
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/<name>/) 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="auto" or 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 vars -> "openrouter" 4. OpenRouter
credential pool 5.
"""
normalized = (requested or "auto").strip().lower()
# Normalize provider aliases. Extend with aliases declared in
# plugins/model-providers/<name>/ 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"
# Provider precedence for the auto-path (#29285): explicit user intent must
# win over a stale logged-in OAuth `active_provider`. Order matches the
# docstring: 1. explicit CLI creds 2. config.yaml `model.provider`
# 3. OPENAI/OPENROUTER env keys 4. OpenRouter pool 5. provider-specific
# env keys 6. auth.json `active_provider` (OAuth) 7. Bedrock 8. error.
# 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 _iso_after(now: datetime, ttl_seconds: int) -> str:
"""ISO timestamp *ttl_seconds* after *now* (UTC)."""
return datetime.fromtimestamp(now.timestamp() + ttl_seconds, tz=timezone.utc).isoformat()
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")
# Nous agent-key slots; a fresh login persists them as None, quarantine strips them.
_NOUS_EMPTY_AGENT_KEY_FIELDS: Dict[str, Any] = {
"agent_key": None,
"agent_key_id": None,
"agent_key_expires_at": None,
"agent_key_expires_in": None,
"agent_key_reused": None,
"agent_key_obtained_at": None,
}
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
_NOUS_STALE_PORTAL_HOSTS: FrozenSet[str] = frozenset({
"api.nousresearch.com",
})
# 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",
})
def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None:
nous = providers.get("nous")
if not isinstance(nous, dict):
return
stored = (nous.get("portal_base_url") or "").strip()
if stored:
parsed = urlparse(stored)
if parsed.hostname in _NOUS_STALE_PORTAL_HOSTS:
logger.warning(
"auth: migrating stale nous portal_base_url %s -> %s",
stored, DEFAULT_NOUS_PORTAL_URL,
)
nous["portal_base_url"] = DEFAULT_NOUS_PORTAL_URL
# Allowlist of hosts the Nous Portal proxy is willing to forward inference
# JWTs to. Sending a bearer anywhere else would leak it.
#
# This is consulted only for URLs coming from the NETWORK side (Portal
# refresh responses). User-controlled env-var overrides
# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented
# dev/staging escape hatch and the env source is already trusted (the
# user set it themselves).
_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
"inference-api.nousresearch.com",
})
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
"""Validate a Portal-returned inference URL against the host allowlist.
Defense-in-depth: a compromised refresh response from the Portal API (MITM, malicious response
injection) could otherwise redirect every subsequent proxy request — bearing the user's
inference JWT — to an attacker-controlled endpoint.
"""
if not isinstance(url, str):
return None
cleaned = url.strip()
if not cleaned:
return None
try:
parsed = urlparse(cleaned)
except Exception:
return None
if parsed.scheme != "https":
logger.warning(
"nous: refusing non-https inference URL scheme %r from Portal response",
parsed.scheme,
)
return None
if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS:
logger.warning(
"nous: refusing inference URL host %r from Portal response "
"(not in allowlist); falling back to default",
parsed.hostname,
)
return None
return cleaned.rstrip("/")
def _nous_inference_env_override() -> Optional[str]:
"""Return the user-set ``NOUS_INFERENCE_BASE_URL`` override, if any.
Documented dev/staging escape hatch. The env source is trusted (the OS user set it), so unlike
Portal-returned URLs it is intentionally NOT gated by the network host allowlist.
Returns a trailing-slash-stripped string, or ``None`` when unset/blank.
"""
return _optional_base_url(os.getenv("NOUS_INFERENCE_BASE_URL"))
def _nous_portal_env_override() -> Optional[str]:
"""Return the user/deployment-set Portal base URL override, if any.
``HERMES_PORTAL_BASE_URL`` / ``NOUS_PORTAL_BASE_URL`` are the documented dev/staging escape
hatch (e.g. hosted agents on the staging Portal). Like the inference override, the env source
is trusted and must NOT be gated by ``_NOUS_PORTAL_ALLOWED_HOSTS``: that allowlist rejects an
untrusted NETWORK-provided value persisted to auth.json, not one the operator configured.
"""
return _optional_base_url(
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
)
def _decode_jwt_claims(token: Any) -> Dict[str, Any]:
if not isinstance(token, str) or token.count(".") != 2:
return {}
payload = token.split(".")[1]
payload += "=" * ((4 - len(payload) % 4) % 4)
try:
raw = base64.urlsafe_b64decode(payload.encode("utf-8"))
claims = json.loads(raw.decode("utf-8"))
except Exception:
return {}
return claims if isinstance(claims, dict) else {}
def _scope_values(raw_scope: Any) -> set[str]:
# OAuth token responses normally return a space-separated string. Keep
# collection support for JWT ``scp`` claims and older stored test fixtures.
scopes: set[str] = set()
if isinstance(raw_scope, str):
for part in raw_scope.replace(",", " ").split():
cleaned = part.strip()
if cleaned:
scopes.add(cleaned)
elif isinstance(raw_scope, (list, tuple, set, frozenset)):
for item in raw_scope:
if isinstance(item, str):
scopes.update(_scope_values(item))
return scopes
def _nous_invoke_jwt_status(
token: Any,
*,
scope: Any = None,
expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
) -> Optional[str]:
"""Return None when the token can be used for inference, else a reason."""
claims = _decode_jwt_claims(token)
if not claims:
return "access_token_not_jwt"
scopes = (
_scope_values(scope)
| _scope_values(claims.get("scope"))
| _scope_values(claims.get("scp"))
)
if NOUS_INFERENCE_INVOKE_SCOPE not in scopes:
return "missing_inference_invoke_scope"
exp = claims.get("exp")
skew = max(0, int(min_ttl_seconds))
if isinstance(exp, (int, float)):
if float(exp) <= (time.time() + skew):
return "invoke_jwt_expiring"
return None
if _is_expiring(expires_at, skew):
return "invoke_jwt_expiry_unknown_or_expiring"
return None
def _nous_invoke_jwt_is_usable(
token: Any,
*,
scope: Any = None,
expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
) -> bool:
return (
_nous_invoke_jwt_status(
token,
scope=scope,
expires_at=expires_at,
min_ttl_seconds=min_ttl_seconds,
)
is None
)
def _assert_nous_inference_jwt_usable(
state: Dict[str, Any],
*,
access_token: Any = None,
) -> None:
token = state.get("access_token") if access_token is None else access_token
reason = _nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if reason is None:
return
raise _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({reason}). Re-authenticate with: hermes auth add nous",
reason, relogin=True,
)
def _log_nous_invoke_jwt_selected(
*,
access_token: Any,
sequence_id: Optional[str] = None,
) -> None:
logger.debug("Nous inference auth: using NAS invoke JWT")
_oauth_trace(
"nous_invoke_jwt_selected",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]:
claims = _decode_jwt_claims(token)
exp = claims.get("exp")
if isinstance(exp, (int, float)):
try:
return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
except Exception:
pass
return fallback_expires_at if isinstance(fallback_expires_at, str) else None
def _set_nous_agent_key_from_invoke_jwt(
state: Dict[str, Any],
*,
obtained_at: Optional[str] = None,
) -> None:
access_token = state.get("access_token")
if not _nonempty_str(access_token):
return
now = datetime.now(timezone.utc)
existing_obtained_at = state.get("agent_key_obtained_at")
if obtained_at:
effective_obtained_at = obtained_at
elif (
state.get("agent_key") == access_token
and isinstance(existing_obtained_at, str)
and existing_obtained_at.strip()
):
effective_obtained_at = existing_obtained_at
else:
effective_obtained_at = now.isoformat()
expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at"))
expires_epoch = _parse_iso_timestamp(expires_at)
expires_in = (
max(0, int(expires_epoch - time.time()))
if expires_epoch is not None
else _coerce_ttl_seconds(state.get("expires_in"))
)
if expires_at:
state["expires_at"] = expires_at
state["expires_in"] = expires_in
state["agent_key"] = access_token
state["agent_key_id"] = None
state["agent_key_expires_at"] = expires_at
state["agent_key_expires_in"] = expires_in
state["agent_key_reused"] = False
state["agent_key_obtained_at"] = effective_obtained_at
def _select_nous_invoke_jwt(
state: Dict[str, Any],
*,
access_token: Any = None,
sequence_id: Optional[str] = None,
) -> None:
if _nonempty_str(access_token):
state["access_token"] = access_token
_set_nous_agent_key_from_invoke_jwt(state)
_log_nous_invoke_jwt_selected(
access_token=state.get("access_token"),
sequence_id=sequence_id,
)
_NOUS_EFFECTIVE_STATE_IGNORED_KEYS = frozenset({
# These are derived from expires_at/JWT exp and naturally tick down between
# reads. Persisting only these changes makes auth.json noisy and defeats
# the mtime-keyed auth-status cache.
"expires_in",
"agent_key_expires_in",
})
def _nous_effective_provider_state(state: Dict[str, Any]) -> Dict[str, Any]:
return {
key: value
for key, value in state.items()
if key not in _NOUS_EFFECTIVE_STATE_IGNORED_KEYS
}
def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool:
claims = _decode_jwt_claims(access_token)
exp = claims.get("exp")
if not isinstance(exp, (int, float)):
return False
return float(exp) <= (time.time() + max(0, int(skew_seconds)))
def _qwen_cli_auth_path() -> Path:
return Path.home() / ".qwen" / "oauth_creds.json"
def _read_qwen_cli_tokens() -> Dict[str, Any]:
auth_path = _qwen_cli_auth_path()
if not auth_path.exists():
raise _qwen_err(
"Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.",
"qwen_auth_missing",
)
try:
data = json.loads(auth_path.read_text(encoding="utf-8"))
except Exception as exc:
raise _qwen_err(
f"Failed to read Qwen CLI credentials from {auth_path}: {exc}",
"qwen_auth_read_failed",
) from exc
if not isinstance(data, dict):
raise _qwen_err(f"Invalid Qwen CLI credentials in {auth_path}.", "qwen_auth_invalid")
return data
def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:
auth_path = _qwen_cli_auth_path()
_write_private_file_atomic(auth_path, json.dumps(tokens, indent=2, sort_keys=True) + "\n")
return auth_path
def _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
try:
expiry_ms = int(expiry_date_ms)
except Exception:
return True
return (time.time() + max(0, int(skew_seconds))) * 1000 >= expiry_ms
def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20.0) -> Dict[str, Any]:
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if not refresh_token:
raise _qwen_err(
"Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.",
"qwen_refresh_token_missing",
)
try:
response = httpx.post(
QWEN_OAUTH_TOKEN_URL,
headers=_FORM_JSON_HEADERS,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": QWEN_OAUTH_CLIENT_ID,
},
timeout=timeout_seconds,
)
except Exception as exc:
raise _qwen_err(f"Qwen OAuth refresh failed: {exc}", "qwen_refresh_failed") from exc
if response.status_code >= 400:
body = response.text.strip()
raise _qwen_err(
"Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'."
+ (f" Response: {body}" if body else ""),
"qwen_refresh_failed",
)
try:
payload = response.json()
except Exception as exc:
raise _qwen_err(
f"Qwen OAuth refresh returned invalid JSON: {exc}",
"qwen_refresh_invalid_json",
) from exc
if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
raise _qwen_err(
"Qwen OAuth refresh response missing access_token.",
"qwen_refresh_invalid_response",
)
expires_in = payload.get("expires_in")
try:
expires_in_seconds = int(expires_in)
except Exception:
expires_in_seconds = 6 * 60 * 60
refreshed = {
"access_token": str(payload.get("access_token", "") or "").strip(),
"refresh_token": str(payload.get("refresh_token", refresh_token) or refresh_token).strip(),
"token_type": str(payload.get("token_type", tokens.get("token_type", "Bearer")) or "Bearer").strip() or "Bearer",
"resource_url": str(payload.get("resource_url", tokens.get("resource_url", "portal.qwen.ai")) or "portal.qwen.ai").strip(),
"expiry_date": int(time.time() * 1000) + max(1, expires_in_seconds) * 1000,
}
_save_qwen_cli_tokens(refreshed)
return refreshed
def _mark_qwen_oauth_active(creds: Dict[str, Any]) -> None:
"""Set active_provider to qwen-oauth in auth.json.
Qwen tokens live in the Qwen CLI credential file, so this writes only a minimal provider-state
entry (base_url for display) and sets active_provider so ``get_active_provider()`` and the
setup wizard's credential check detect the provider.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
state: Dict[str, Any] = {}
if creds.get("base_url"):
state["base_url"] = str(creds["base_url"])
_save_provider_state(auth_store, "qwen-oauth", state)
_save_auth_store(auth_store)
def resolve_qwen_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
refresh_skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
tokens = _read_qwen_cli_tokens()
access_token = str(tokens.get("access_token", "") or "").strip()
should_refresh = bool(force_refresh)
if not should_refresh and refresh_if_expiring:
should_refresh = _qwen_access_token_is_expiring(tokens.get("expiry_date"), refresh_skew_seconds)
if should_refresh:
tokens = _refresh_qwen_cli_tokens(tokens)
access_token = str(tokens.get("access_token", "") or "").strip()
if not access_token:
raise _qwen_err(
"Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.",
"qwen_access_token_missing",
)
base_url = os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL
return {
"provider": "qwen-oauth",
"base_url": base_url,
"api_key": access_token,
"source": "qwen-cli",
"expires_at_ms": tokens.get("expiry_date"),
"auth_file": str(_qwen_cli_auth_path()),
}
def get_qwen_auth_status() -> Dict[str, Any]:
auth_path = _qwen_cli_auth_path()
try:
# Validate the runtime credentials, including refresh when the cached
# CLI token is expired. Otherwise stale tokens show up as "logged in"
# and `hermes model` walks users into a broken Qwen setup flow.
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=True)
return {
"logged_in": True,
"auth_file": str(auth_path),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
"expires_at_ms": creds.get("expires_at_ms"),
}
except AuthError as exc:
return {
"logged_in": False,
"auth_file": str(auth_path),
"error": str(exc),
}
# =============================================================================
# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
# =============================================================================
def _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]:
scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip()
scopes = [part for part in scope_text.split() if part]
seen: set[str] = set()
ordered: List[str] = []
for scope in scopes:
if scope not in seen:
seen.add(scope)
ordered.append(scope)
return ordered
def _spotify_scope_string(raw_scope: Optional[str] = None) -> str:
return " ".join(_spotify_scope_list(raw_scope))
def _spotify_setting(
state: Optional[Dict[str, Any]],
state_key: str,
env_vars: Tuple[str, ...],
default: str,
*,
explicit: Optional[str] = None,
strip_slash: bool = False,
) -> str:
"""First non-empty of explicit arg, env vars (``.env`` aware), stored state, then *default*."""
from hermes_cli.config import get_env_value
candidates = (
explicit,
*(get_env_value(var) for var in env_vars),
state.get(state_key) if isinstance(state, dict) else None,
default,
)
for candidate in candidates:
cleaned = str(candidate or "").strip()
if strip_slash:
cleaned = cleaned.rstrip("/")
if cleaned:
return cleaned
return default
def _spotify_client_id(
explicit: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> str:
client_id = _spotify_setting(
state, "client_id", ("HERMES_SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_ID"), "", explicit=explicit,
)
if client_id:
return client_id
raise _spotify_err(
"Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.",
"spotify_client_id_missing",
)
def _spotify_redirect_uri(
explicit: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> str:
return _spotify_setting(
state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"),
DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit,
)
def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str:
return _spotify_setting(
state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",),
DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True,
)
def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str:
return _spotify_setting(
state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",),
DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, strip_slash=True,
)
def _spotify_code_verifier(length: int = 64) -> str:
raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii")
return raw.rstrip("=")[:128]
def _spotify_code_challenge(code_verifier: str) -> str:
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _spotify_build_authorize_url(
*,
client_id: str,
redirect_uri: str,
scope: str,
state: str,
code_challenge: str,
accounts_base_url: str,
) -> str:
query = urlencode({
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"code_challenge_method": "S256",
"code_challenge": code_challenge,
})
return f"{accounts_base_url}/authorize?{query}"
def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]:
parsed = urlparse(redirect_uri)
if parsed.scheme != "http":
raise _spotify_err(
"Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.",
"spotify_redirect_invalid",
)
host = parsed.hostname or ""
if host not in {"127.0.0.1", "localhost"}:
raise _spotify_err(
"Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.",
"spotify_redirect_invalid",
)
if not parsed.port:
raise _spotify_err(
"Spotify PKCE redirect_uri must include an explicit localhost port.",
"spotify_redirect_invalid",
)
return host, parsed.port, parsed.path or "/"
def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:
result: dict[str, Any] = {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
class _SpotifyCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path != expected_path:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found.")
return
params = parse_qs(parsed.query)
result["code"] = params.get("code", [None])[0]
result["state"] = params.get("state", [None])[0]
result["error"] = params.get("error", [None])[0]
result["error_description"] = params.get("error_description", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
if result["error"]:
body = "<html><body><h1>Spotify authorization failed.</h1>You can close this tab.</body></html>"
else:
body = "<html><body><h1>Spotify authorization received.</h1>You can close this tab.</body></html>"
self.wfile.write(body.encode("utf-8"))
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
return _SpotifyCallbackHandler, result
def _spotify_wait_for_callback(
redirect_uri: str,
*,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
host, port, path = _spotify_validate_redirect_uri(redirect_uri)
handler_cls, result = _make_spotify_callback_handler(path)
class _ReuseHTTPServer(HTTPServer):
allow_reuse_address = True
try:
server = _ReuseHTTPServer((host, port), handler_cls)
except OSError as exc:
raise _spotify_err(
f"Could not bind Spotify callback server on {host}:{port}: {exc}",
"spotify_callback_bind_failed",
) from exc
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
thread.start()
deadline = time.monotonic() + max(5.0, timeout_seconds)
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
time.sleep(0.1)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1.0)
raise _spotify_err(
"Spotify authorization timed out waiting for the local callback.",
"spotify_callback_timeout",
)
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
client_id: str,
redirect_uri: str,
requested_scope: str,
accounts_base_url: str,
api_base_url: str,
previous_state: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc)
expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0))
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
state = dict(previous_state or {})
state.update({
"client_id": client_id,
"redirect_uri": redirect_uri,
"accounts_base_url": accounts_base_url,
"api_base_url": api_base_url,
"scope": requested_scope,
"granted_scope": str(token_payload.get("scope") or requested_scope).strip(),
"token_type": str(token_payload.get("token_type", "Bearer") or "Bearer").strip() or "Bearer",
"access_token": str(token_payload.get("access_token", "") or "").strip(),
"refresh_token": str(
token_payload.get("refresh_token")
or state.get("refresh_token")
or ""
).strip(),
"obtained_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
"expires_in": expires_in,
"auth_type": "oauth_pkce",
})
return state
def _spotify_token_post(
accounts_base_url: str,
data: Dict[str, str],
*,
timeout_seconds: float,
what: str,
failed_code: str,
invalid_code: str,
invalid_message: str,
failed_suffix: str = "",
relogin_required: bool = False,
) -> Dict[str, Any]:
"""POST to Spotify's ``/api/token`` and return the JSON payload, or raise a shaped AuthError."""
try:
response = httpx.post(
f"{accounts_base_url}/api/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=data,
timeout=timeout_seconds,
)
except Exception as exc:
raise _spotify_err(f"Spotify {what} failed: {exc}", failed_code) from exc
if response.status_code >= 400:
detail = response.text.strip()
raise _spotify_err(
f"Spotify {what} failed.{failed_suffix}"
+ (f" Response: {detail}" if detail else ""),
failed_code, relogin=relogin_required,
)
payload = response.json()
if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
raise _spotify_err(invalid_message, invalid_code, relogin=relogin_required)
return payload
def _spotify_exchange_code_for_tokens(
*,
client_id: str,
code: str,
redirect_uri: str,
code_verifier: str,
accounts_base_url: str,
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
return _spotify_token_post(
accounts_base_url,
{
"client_id": client_id,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
timeout_seconds=timeout_seconds,
what="token exchange",
failed_code="spotify_token_exchange_failed",
invalid_code="spotify_token_exchange_invalid",
invalid_message="Spotify token response did not include an access_token.",
)
def _refresh_spotify_oauth_state(
state: Dict[str, Any],
*,
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
refresh_token = str(state.get("refresh_token", "") or "").strip()
if not refresh_token:
raise _spotify_err(
"Spotify refresh token missing. Run `hermes auth spotify` again.",
"spotify_refresh_token_missing", relogin=True,
)
client_id = _spotify_client_id(state=state)
accounts_base_url = _spotify_accounts_base_url(state)
payload = _spotify_token_post(
accounts_base_url,
{
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
},
timeout_seconds=timeout_seconds,
what="token refresh",
failed_code="spotify_refresh_failed",
invalid_code="spotify_refresh_invalid",
invalid_message="Spotify refresh response did not include an access_token.",
failed_suffix=" Run `hermes auth spotify` again.",
relogin_required=True,
)
return _spotify_token_payload_to_state(
payload,
client_id=client_id,
redirect_uri=_spotify_redirect_uri(state=state),
requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE),
accounts_base_url=accounts_base_url,
api_base_url=_spotify_api_base_url(state),
previous_state=state,
)
def resolve_spotify_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "spotify")
if not state:
raise _spotify_err(
"Spotify is not authenticated. Run `hermes auth spotify` first.",
"spotify_auth_missing", relogin=True,
)
should_refresh = bool(force_refresh)
if not should_refresh and refresh_if_expiring:
should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds)
if should_refresh:
try:
state = _refresh_spotify_oauth_state(state)
_store_provider_state(auth_store, "spotify", state, set_active=False)
_save_auth_store(auth_store)
except AuthError as exc:
if exc.relogin_required and state.get("refresh_token"):
_quarantine_flat_oauth_state(state, "spotify", exc)
try:
_store_provider_state(auth_store, "spotify", state, set_active=False)
_save_auth_store(auth_store)
except Exception as _save_exc:
logger.debug("Spotify OAuth: failed to persist quarantined state: %s", _save_exc)
raise
access_token = str(state.get("access_token", "") or "").strip()
if not access_token:
raise _spotify_err(
"Spotify access token missing. Run `hermes auth spotify` again.",
"spotify_access_token_missing", relogin=True,
)
return {
"provider": "spotify",
"access_token": access_token,
"api_key": access_token,
"token_type": str(state.get("token_type", "Bearer") or "Bearer"),
"base_url": _spotify_api_base_url(state),
"scope": str(state.get("granted_scope") or state.get("scope") or "").strip(),
"client_id": _spotify_client_id(state=state),
"redirect_uri": _spotify_redirect_uri(state=state),
"expires_at": state.get("expires_at"),
"refresh_token": str(state.get("refresh_token", "") or "").strip(),
}
def get_spotify_auth_status() -> Dict[str, Any]:
state = get_provider_auth_state("spotify")
if not state:
return {"logged_in": False}
expires_at = state.get("expires_at")
refresh_token = str(state.get("refresh_token", "") or "").strip()
return {
"logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)),
"auth_type": state.get("auth_type", "oauth_pkce"),
"client_id": state.get("client_id"),
"redirect_uri": state.get("redirect_uri"),
"scope": state.get("granted_scope") or state.get("scope"),
"expires_at": expires_at,
"api_base_url": state.get("api_base_url"),
"has_refresh_token": bool(refresh_token),
}
def _spotify_interactive_setup(redirect_uri_hint: str) -> str:
"""Walk the user through creating a Spotify developer app, persist the resulting client_id to
~/.hermes/.env, and return it.
"""
from hermes_cli.config import save_env_value
print()
print("=" * 70)
print("Spotify first-time setup")
print("=" * 70)
print()
print("Spotify requires every user to register their own lightweight")
print("developer app. This takes about two minutes and only has to be")
print("done once per machine.")
print()
print(f"Full guide: {SPOTIFY_DOCS_URL}")
print()
print("Steps:")
print(f" 1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...")
print(" 2. Click 'Create app' and fill in:")
print(" App name: anything (e.g. hermes-agent)")
print(" Description: anything")
print(f" Redirect URI: {redirect_uri_hint}")
print(" API/SDK: Web API")
print(" 3. Agree to the terms, click Save.")
print(" 4. Open the app's Settings page and copy the Client ID.")
print(" 5. Paste it below.")
print()
if not _is_remote_session():
try:
webbrowser.open(SPOTIFY_DASHBOARD_URL)
except Exception:
pass
from hermes_cli.cli_output import line_input
try:
raw = line_input("Spotify Client ID: ").strip()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit("Spotify setup cancelled.")
if not raw:
print()
print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.")
raise SystemExit("Spotify setup cancelled: empty Client ID.")
# Persist so subsequent `hermes auth spotify` runs skip the wizard.
save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw)
# Only persist the redirect URI if it's non-default, to avoid pinning
# users to a value the default might later change to.
if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI:
save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint)
print()
print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env")
print()
return raw
def login_spotify_command(args) -> None:
existing_state = get_provider_auth_state("spotify") or {}
# Interactive wizard: if no client_id is configured anywhere, walk the
# user through creating the Spotify developer app instead of crashing
# with "HERMES_SPOTIFY_CLIENT_ID is required".
explicit_client_id = getattr(args, "client_id", None)
try:
client_id = _spotify_client_id(explicit_client_id, existing_state)
except AuthError as exc:
if getattr(exc, "code", "") != "spotify_client_id_missing":
raise
client_id = _spotify_interactive_setup(
redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI,
)
redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state)
scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope"))
accounts_base_url = _spotify_accounts_base_url(existing_state)
api_base_url = _spotify_api_base_url(existing_state)
open_browser = not getattr(args, "no_browser", False)
code_verifier = _spotify_code_verifier()
code_challenge = _spotify_code_challenge(code_verifier)
state_nonce = uuid.uuid4().hex
authorize_url = _spotify_build_authorize_url(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state_nonce,
code_challenge=code_challenge,
accounts_base_url=accounts_base_url,
)
print("Starting Spotify PKCE login...")
print(f"Client ID: {client_id}")
print(f"Redirect URI: {redirect_uri}")
print("Make sure this redirect URI is allow-listed in your Spotify app settings.")
print()
print("Open this URL to authorize Hermes:")
print(authorize_url)
print()
print(f"Full setup guide: {SPOTIFY_DOCS_URL}")
print()
_print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL)
if open_browser and not _is_remote_session() and _can_open_graphical_browser():
try:
opened = webbrowser.open(authorize_url)
except Exception:
opened = False
if opened:
print("Browser opened for Spotify authorization.")
else:
print("Could not open the browser automatically; use the URL above.")
callback = _spotify_wait_for_callback(
redirect_uri,
timeout_seconds=float(getattr(args, "timeout", None) or 180.0),
)
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
raise SystemExit(f"Spotify authorization failed: {detail}")
if callback.get("state") != state_nonce:
raise SystemExit("Spotify authorization failed: state mismatch.")
token_payload = _spotify_exchange_code_for_tokens(
client_id=client_id,
code=str(callback.get("code") or ""),
redirect_uri=redirect_uri,
code_verifier=code_verifier,
accounts_base_url=accounts_base_url,
timeout_seconds=float(getattr(args, "timeout", None) or 20.0),
)
spotify_state = _spotify_token_payload_to_state(
token_payload,
client_id=client_id,
redirect_uri=redirect_uri,
requested_scope=scope,
accounts_base_url=accounts_base_url,
api_base_url=api_base_url,
)
with _auth_store_lock():
auth_store = _load_auth_store()
_store_provider_state(auth_store, "spotify", spotify_state, set_active=False)
saved_to = _save_auth_store(auth_store)
print("Spotify login successful!")
print(f" Auth state: {saved_to}")
print(" Provider state saved under providers.spotify")
print(f" Docs: {SPOTIFY_DOCS_URL}")
# =============================================================================
# SSH / remote session detection
# =============================================================================
def _is_remote_session() -> bool:
"""Detect environments where loopback OAuth can't reach the local browser.
These environments typically don't set ``SSH_CLIENT`` / ``SSH_TTY``, so the SSH-only check left
them with no guidance and no fallback.
"""
if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"):
return True
# Browser-only remote IDEs / cloud shells. Keep this list narrow
# (well-known, documented env vars set by the host platform) so
# we don't falsely trip on a developer's local shell.
for var in (
"CLOUD_SHELL", # GCP Cloud Shell
"CODESPACES", # GitHub Codespaces
"CODESPACE_NAME", # GitHub Codespaces (alt)
"GITPOD_WORKSPACE_ID", # Gitpod
"REPL_ID", # Replit
"STACKBLITZ", # StackBlitz
):
if os.getenv(var):
return True
return False
# Console/text-mode browsers that ``webbrowser`` will happily launch INSIDE
# the terminal. Opening one of these is worse than not opening anything —
# it hijacks the user's TTY with an unusable text browser (the xAI OAuth
# "Account Management" page rendered in w3m, reported May 2026) instead of
# letting them copy the URL to a real browser. When the resolved browser is
# one of these we refuse to auto-open and fall back to the print-the-URL
# path, same as a remote session.
_CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset(
{
"w3m",
"lynx",
"links",
"links2",
"elinks",
"www-browser",
"browsh", # TUI browser — still hijacks the terminal
}
)
def _can_open_graphical_browser() -> bool:
"""Return True only when a *graphical* browser is likely to open.
``webbrowser.open()`` resolves to whatever the platform offers, and on a headless / CLI-only
Linux box with no GUI browser installed that is often a text-mode browser (w3m/lynx/links) which
launches inside the terminal and takes over the user's session.
Heuristics: * Respect ``$BROWSER`` — if it names a known console browser, refuse. * On Linux,
require a display server (``$DISPLAY`` / ``$WAYLAND_DISPLAY``) unless ``$BROWSER`` points at
something graphical; no display server almost always means no GUI browser.
"""
import webbrowser as _webbrowser
def _names_console_browser(value: str) -> bool:
token = value.strip().split()[0] if value.strip() else ""
base = os.path.basename(token).lower()
return base in _CONSOLE_BROWSER_NAMES
browser_env = os.environ.get("BROWSER", "")
if browser_env and _names_console_browser(browser_env):
return False
if sys.platform.startswith("linux"):
has_display = bool(
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
)
# An explicit graphical $BROWSER can work without $DISPLAY in odd
# setups, but a console $BROWSER already returned False above, so the
# only way to reach here with a $BROWSER set is a graphical one.
if not has_display and not browser_env:
return False
try:
controller = _webbrowser.get()
except Exception:
# No browser resolvable at all → definitely don't auto-open.
return False
candidate = (
getattr(controller, "name", "")
or getattr(controller, "basename", "")
or ""
)
return not (candidate and _names_console_browser(candidate))
def _ssh_user_at_host() -> str:
"""Return best-effort 'user@hostname' for the SSH tunnel hint command.
Falls back to placeholder tokens when the values cannot be determined so the hint is always
syntactically valid even if not copy-pasteable.
"""
try:
import socket as _socket
hostname = _socket.gethostname() or "<this-host>"
except OSError:
hostname = "<this-host>"
user = os.getenv("USER") or os.getenv("LOGNAME") or "<user>"
return f"{user}@{hostname}"
def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None:
"""Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a remote host. The auth
server (Spotify, MCP servers, ...) will redirect the user's browser to
``127.0.0.1:<port>/callback``. If the browser is on a different machine than the loopback
listener (the usual SSH case), the redirect can't reach the listener without a local port
forward.
"""
if not _is_remote_session():
return
try:
parsed = urlparse(redirect_uri)
except Exception:
return
host = parsed.hostname or ""
port = parsed.port
if host not in {"127.0.0.1", "::1", "localhost"} or not port:
return
divider = "-" * 60
print()
print(divider)
print("Remote session detected — SSH tunnel required")
print(divider)
print(f"Hermes is waiting for the OAuth callback on {redirect_uri}")
print("but your browser is on a different machine. Run this command")
print("in a NEW terminal on your local machine BEFORE opening the URL:")
print()
print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}")
print()
print("Then open the authorize URL above in your local browser.")
if docs_url:
print(f"Provider docs: {docs_url}")
print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}")
print(divider)
print()
# =============================================================================
# OpenAI Codex auth — tokens stored in ~/.hermes/auth.json (not ~/.codex/)
#
# Hermes maintains its own Codex OAuth session separate from the Codex CLI
# and VS Code extension. This prevents refresh token rotation conflicts
# where one app's refresh invalidates the other's session.
# =============================================================================
def _codex_base_url() -> str:
return os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL
def _codex_runtime_result(api_key: str, *, source: str, last_refresh: Optional[str]) -> Dict[str, Any]:
return {
"provider": "openai-codex",
"base_url": _codex_base_url(),
"api_key": api_key,
"source": source,
"last_refresh": last_refresh,
"auth_mode": "chatgpt",
}
def _load_auth_store_maybe_locked(lock: bool) -> Dict[str, Any]:
"""Load the auth store, taking the cross-process lock unless the caller already holds it."""
if lock:
with _auth_store_lock():
return _load_auth_store()
return _load_auth_store()
def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
"""Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json)."""
auth_store = _load_auth_store_maybe_locked(_lock)
state = _load_provider_state(auth_store, "openai-codex")
if not state:
raise _codex_err(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
"codex_auth_missing", relogin=True,
)
tokens = state.get("tokens")
if not isinstance(tokens, dict):
raise _codex_err(
"Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.",
"codex_auth_invalid_shape", relogin=True,
)
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not _nonempty_str(access_token):
raise _codex_err(
"Codex auth is missing access_token. Run `hermes auth` to re-authenticate.",
"codex_auth_missing_access_token", relogin=True,
)
if not _nonempty_str(refresh_token):
raise _codex_err(
"Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
"codex_auth_missing_refresh_token", relogin=True,
)
return {
"tokens": tokens,
"last_refresh": state.get("last_refresh"),
}
def _sync_codex_pool_entries(
auth_store: Dict[str, Any],
tokens: Dict[str, str],
last_refresh: Optional[str],
previous_singleton_tokens: Optional[Dict[str, str]] = None,
) -> None:
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
* ``device_code`` — the singleton-seeded entry written by the device-code OAuth flow when the
user logged in via ``hermes setup`` / the model picker. Always synced with the fresh tokens. *
``manual:device_code`` — entries created by ``hermes auth add openai-codex`` that use the same
device-code OAuth mechanism.
* ``manual:api_key`` and any other non-device-code manual sources — those are independent
credentials (an explicit API key, a different ChatGPT account, etc.) and must not be overwritten
by a single re-auth.
"""
access_token = tokens.get("access_token")
if not access_token:
return
refresh_token = tokens.get("refresh_token")
entries = _pool_entries(auth_store, "openai-codex")
if entries is None:
return
# Previous singleton access_token (before this re-auth overwrote it) —
# used to distinguish legacy singleton-aliases from independent accounts.
# When None or empty, no manual entry can be treated as an alias (which
# is the right default for first-ever-save or a freshly initialized
# auth.json).
prev_at = None
if isinstance(previous_singleton_tokens, dict):
prev_at = previous_singleton_tokens.get("access_token") or None
for entry in entries:
if not isinstance(entry, dict):
continue
source = entry.get("source")
if source == "device_code":
# Singleton-seeded mirror — always refresh.
refresh_this_entry = True
elif source == "manual:device_code":
# Refresh only if this entry's existing access_token matches the
# previous singleton access_token (i.e. it is a true alias of the
# singleton from the #33000 workaround era). An entry with its
# own distinct token material is an independent account and must
# be left alone (#39236).
refresh_this_entry = bool(
prev_at and entry.get("access_token") == prev_at
)
else:
# ``manual:api_key`` and any future non-device-code sources.
refresh_this_entry = False
if not refresh_this_entry:
continue
entry["access_token"] = access_token
if refresh_token:
entry["refresh_token"] = refresh_token
if last_refresh:
entry["last_refresh"] = last_refresh
_clear_pool_entry_status(entry)
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
if last_refresh is None:
last_refresh = _utc_now_z()
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
# Capture the previous singleton tokens BEFORE overwriting them. The
# pool-sync step uses this to distinguish legacy singleton-aliases
# (which should be refreshed) from independent accounts that
# ``hermes auth add openai-codex`` created (which must not be
# overwritten — see #39236).
previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
if label and str(label).strip():
state["label"] = str(label).strip()
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(
auth_store,
tokens,
last_refresh,
previous_singleton_tokens=previous_singleton_tokens,
)
_save_auth_store(auth_store)
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
imported = _import_codex_cli_tokens()
# Require BOTH tokens before adopting: persisting a payload without a
# usable refresh_token would only break the next refresh cycle.
if not (
imported
and str(imported.get("access_token", "") or "").strip()
and str(imported.get("refresh_token", "") or "").strip()
):
return None
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
_save_codex_tokens(imported)
return dict(imported)
def _refresh_payload_access_token(
response: "httpx.Response",
*,
provider: str,
invalid_json: Tuple[str, str],
invalid_response: Optional[Tuple[str, str]],
missing_access: Tuple[str, str],
relogin_required: bool = True,
invalid_json_relogin: Optional[bool] = None,
strict_str: bool = True,
) -> Tuple[Dict[str, Any], str]:
"""Parse a 200 token-refresh response; return ``(payload, stripped access_token)``.
Each ``(message, code)`` pair keeps the provider's historical wording; ``{exc}`` in
*invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string
access tokens; otherwise they are ``str()``-coerced.
"""
try:
payload = response.json()
except Exception as exc:
raise AuthError(
invalid_json[0].format(exc=exc),
provider=provider,
code=invalid_json[1],
relogin_required=(
relogin_required if invalid_json_relogin is None else invalid_json_relogin
),
) from exc
if not isinstance(payload, dict):
if invalid_response is None:
payload = {}
else:
raise AuthError(
invalid_response[0],
provider=provider,
code=invalid_response[1],
relogin_required=relogin_required,
)
access = payload.get("access_token")
if strict_str:
access = access.strip() if isinstance(access, str) else ""
else:
access = str(access or "").strip()
if not access:
raise AuthError(
missing_access[0],
provider=provider,
code=missing_access[1],
relogin_required=relogin_required,
)
return payload, access
def _codex_http_client(**kwargs: Any) -> "httpx.Client":
"""Build an ``httpx.Client`` for Codex OAuth/probe endpoints with racing.
Same broken-IPv6 failure mode as the chat transport (#13834): a host that advertises AAAA
records but blackholes IPv6 makes each serial connect attempt eat the full connect timeout
before IPv4 is tried, so token refresh / device login / usage probes time out where the official
Codex CLI (which races families per RFC 8305) works.
Best-effort: if the racing backend can't be installed (unexpected httpx/httpcore internals,
mocked client in tests), the client still works with the default serial connect behavior.
"""
client = httpx.Client(**kwargs)
try:
from agent.process_bootstrap import enable_happy_eyeballs_on_client
enable_happy_eyeballs_on_client(client)
except Exception:
pass
return client
def _codex_quota_exhausted_error(retry_after: Optional[int]) -> AuthError:
if retry_after is not None:
message = (
f"Codex provider quota exhausted (429); retry after {retry_after}s. "
"Credentials are still valid."
)
else:
message = (
"Codex provider quota exhausted (429). Credentials are still valid; "
"retry after the usage limit resets."
)
return _codex_err(message, CODEX_RATE_LIMITED_CODE, relogin=False)
def _codex_refresh_failure_error(response: "httpx.Response") -> AuthError:
"""Decode a non-200 Codex token-refresh response into a shaped AuthError."""
code = "codex_refresh_failed"
message = f"Codex token refresh failed with status {response.status_code}."
relogin_required = False
try:
err = response.json()
if isinstance(err, dict):
err_obj = err.get("error")
# OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}}
if isinstance(err_obj, dict):
nested_code = err_obj.get("code") or err_obj.get("type")
if _nonempty_str(nested_code):
code = nested_code.strip()
nested_msg = err_obj.get("message")
if _nonempty_str(nested_msg):
message = f"Codex token refresh failed: {nested_msg.strip()}"
# OAuth spec shape: {"error": "code_str", "error_description": "..."}
elif _nonempty_str(err_obj):
code = err_obj.strip()
err_desc = err.get("error_description") or err.get("message")
if _nonempty_str(err_desc):
message = f"Codex token refresh failed: {err_desc.strip()}"
except Exception:
pass
if code in {"invalid_grant", "invalid_token", "invalid_request"}:
relogin_required = True
if code == "refresh_token_reused":
message = (
"Codex refresh token was already consumed by another client "
"(e.g. Codex CLI or VS Code extension). "
"Run `codex` in your terminal to generate fresh tokens, "
"then run `hermes auth` to re-authenticate."
)
relogin_required = True
# A 401/403 from the token endpoint always means the refresh token
# is invalid/expired — force relogin even if the body error code
# wasn't one of the known strings above.
if response.status_code in {401, 403} and not relogin_required:
relogin_required = True
return _codex_err(message, code, relogin=relogin_required)
def refresh_codex_oauth_pure(
access_token: str,
refresh_token: str,
*,
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
"""Refresh Codex OAuth tokens without mutating Hermes auth state."""
del access_token # Access token is only used by callers to decide whether to refresh.
if not _nonempty_str(refresh_token):
raise _codex_err(
"Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
"codex_auth_missing_refresh_token", relogin=True,
)
timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
with _codex_http_client(
timeout=timeout,
headers={
"Accept": "application/json",
"User-Agent": CODEX_OAUTH_USER_AGENT,
},
) as client:
response = client.post(
CODEX_OAUTH_TOKEN_URL,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CODEX_OAUTH_CLIENT_ID,
},
)
if response.status_code == 429:
# Upstream rate-limit / usage-quota exhaustion on the token endpoint.
# The stored refresh token is still valid here — re-authenticating
# cannot lift a quota cap. Classify distinctly from auth failures so
# callers surface a "retry later" notice instead of a misleading
# "run hermes auth" prompt (see issue #32790).
raise _codex_quota_exhausted_error(
_parse_retry_after_seconds(getattr(response, "headers", None))
)
if response.status_code != 200:
raise _codex_refresh_failure_error(response)
refresh_payload, refreshed_access = _refresh_payload_access_token(
response,
provider="openai-codex",
invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"),
invalid_response=None,
missing_access=(
"Codex token refresh response was missing access_token.",
"codex_refresh_missing_access_token",
),
)
updated = {
"access_token": refreshed_access,
"refresh_token": refresh_token.strip(),
"last_refresh": _utc_now_z(),
}
next_refresh = refresh_payload.get("refresh_token")
if _nonempty_str(next_refresh):
updated["refresh_token"] = next_refresh.strip()
return updated
def _refresh_codex_auth_tokens(
tokens: Dict[str, str],
timeout_seconds: float,
) -> Dict[str, str]:
"""Refresh Codex access token using the refresh token."""
try:
refreshed = refresh_codex_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
timeout_seconds=timeout_seconds,
)
except AuthError as exc:
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
# Codex OAuth token (per profile + top-level), separate from the Codex
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
# the Codex CLI (or another Hermes process) rotates the shared token,
# this frozen copy's refresh_token goes stale and the refresh fails with
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
# Before surfacing that as a hard 401 to the turn, adopt the canonical
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
# idle profiles / desktop sessions recover automatically instead of
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
# keep relogin_required=False — the stored token is still valid there, so
# we never self-heal those and re-raise unchanged.
if not getattr(exc, "relogin_required", False):
raise
imported = _recover_codex_tokens_from_cli(
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
)
if not imported:
raise
return imported
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
_save_codex_tokens(updated_tokens)
return updated_tokens
def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
"""Try to read tokens from ~/.codex/auth.json (Codex CLI shared file).
Returns tokens dict if valid and not expired, None otherwise. Does NOT write to the shared file.
"""
codex_home = os.getenv("CODEX_HOME", "").strip()
if not codex_home:
codex_home = str(Path.home() / ".codex")
auth_path = Path(codex_home).expanduser() / "auth.json"
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text(encoding="utf-8-sig"))
tokens = payload.get("tokens")
if not isinstance(tokens, dict):
return None
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
return None
# Reject expired tokens — importing stale tokens from ~/.codex/
# that can't be refreshed leaves the user stuck with "Login successful!"
# but no working credentials.
if _codex_access_token_is_expiring(access_token, 0):
logger.debug(
"Codex CLI tokens at %s are expired — skipping import.", auth_path,
)
return None
return dict(tokens)
except Exception:
return None
def resolve_codex_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> Dict[str, Any]:
"""Resolve runtime credentials from Hermes's own Codex token store.
Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) has no
usable access_token but the pool (``credential_pool.openai-codex``) does.
"""
read_error: Optional[AuthError] = None
try:
data = _read_codex_tokens()
except AuthError as exc:
read_error = exc
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
"codex_auth_missing_access_token",
"codex_auth_missing_refresh_token",
"codex_auth_invalid_shape",
}:
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
if imported:
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
else:
data = None
else:
data = None
if data is None:
pool_token = _pool_codex_access_token()
if pool_token:
return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None)
pool_rate_limit = _codex_pool_rate_limit_status()
if pool_rate_limit:
# Before surfacing the persisted cooldown, ask the Codex usage
# endpoint whether the quota actually reset early (banked reset
# redeemed, plan upgraded, window reset upstream). The persisted
# ``last_error_reset_at`` can be days in the future while the
# account is already usable again — see issue #43747.
stale_token = str(pool_rate_limit.get("access_token") or "").strip()
if stale_token and _probe_codex_quota_restored(
stale_token,
base_url=pool_rate_limit.get("base_url"),
):
logger.info(
"Codex quota restored upstream — clearing stale pool cooldown(s)."
)
clear_codex_pool_quota_cooldowns()
pool_token = _pool_codex_access_token()
if pool_token:
return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None)
reset_at = pool_rate_limit.get("reset_at")
remaining = (
int(reset_at - time.time())
if isinstance(reset_at, (int, float)) and reset_at > time.time()
else None
)
raise _codex_quota_exhausted_error(remaining)
if read_error is not None:
raise read_error
raise _codex_err(
"No Codex credentials stored. Run `hermes auth` to authenticate.",
"codex_auth_missing", relogin=True,
)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20)
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)
if should_refresh:
# Re-read under lock to avoid racing with other Hermes processes
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
data = _read_codex_tokens(_lock=False)
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)
if should_refresh:
tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
access_token = str(tokens.get("access_token", "") or "").strip()
return _codex_runtime_result(
access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"),
)
def _is_codex_rate_limit_shaped(
code: Any,
reason: Any,
message: Any,
) -> bool:
"""True when persisted pool-entry error metadata describes a 429/quota stop."""
reason_l = str(reason or "").lower()
message_l = str(message or "").lower()
return (
code == 429
or "rate_limit" in reason_l
or "usage_limit" in reason_l
or "quota" in reason_l
or "rate limit" in message_l
or "usage limit" in message_l
or "quota" in message_l
)
# Throttle for the live Codex quota probe below. The probe runs on the hot
# credential-selection path while the pool is exhausted, so without a floor a
# busy gateway would hammer the usage endpoint on every model/auxiliary call.
CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS = 300 # 5 minutes
_codex_quota_probe_cache: Dict[str, Tuple[float, Optional[bool]]] = {}
_codex_quota_probe_lock = threading.Lock()
def _codex_usage_probe_url(base_url: Optional[str]) -> str:
"""Resolve the Codex usage endpoint for a probe.
Mirrors the Codex CLI's PathStyle split: base URLs containing ``/backend-api`` use the ChatGPT
``/wham/usage`` path, everything else ``/api/codex/usage``. Kept local so this low-level auth
module does not import the auxiliary account-usage module.
"""
normalized = str(base_url or "").strip().rstrip("/")
if not normalized:
normalized = _codex_base_url()
if normalized.endswith("/codex"):
normalized = normalized[: -len("/codex")]
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
return prefix + "/usage"
def _probe_codex_quota_restored(
access_token: Any,
*,
base_url: Optional[str] = None,
min_interval_seconds: float = CODEX_QUOTA_PROBE_MIN_INTERVAL_SECONDS,
) -> Optional[bool]:
"""Ask the Codex usage endpoint whether this account's quota is usable again.
Probes are throttled per access token (module-local cache) so the hot selection path can fire
this freely.
"""
token = str(access_token or "").strip()
if not token:
return None
# Real Codex access tokens are JWTs. Refusing to probe non-JWT tokens
# avoids pointless network calls for corrupt/placeholder entries (and
# keeps hermetic test fixtures with dummy tokens offline).
if not _decode_jwt_claims(token):
return None
cache_key = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
now = time.monotonic()
with _codex_quota_probe_lock:
cached = _codex_quota_probe_cache.get(cache_key)
if cached is not None and (now - cached[0]) < min_interval_seconds:
return cached[1]
# Reserve the slot immediately so concurrent selectors don't stampede
# the endpoint while this probe is in flight.
_codex_quota_probe_cache[cache_key] = (now, None)
result: Optional[bool] = None
try:
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
# Best-effort ChatGPT-Account-Id from the JWT (the backend requires it
# for some account shapes; harmless to omit for others).
claims = _decode_jwt_claims(token)
account_id = (
claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
if isinstance(claims.get("https://api.openai.com/auth"), dict)
else None
)
if _nonempty_str(account_id):
headers["ChatGPT-Account-Id"] = account_id.strip()
with _codex_http_client(timeout=10.0) as client:
response = client.get(_codex_usage_probe_url(base_url), headers=headers)
if response.status_code == 200:
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
worst_used: Optional[float] = None
for key in ("primary_window", "secondary_window"):
used = (rate_limit.get(key) or {}).get("used_percent")
if isinstance(used, (int, float)):
worst_used = max(worst_used or 0.0, float(used))
if worst_used is not None:
result = worst_used < 100.0
elif response.status_code == 429:
result = False
except Exception:
logger.debug("Codex quota probe failed", exc_info=True)
result = None
with _codex_quota_probe_lock:
_codex_quota_probe_cache[cache_key] = (now, result)
return result
def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int:
"""Clear rate-limit cooldowns on persisted openai-codex pool entries.
Called after the upstream quota is KNOWN to be restored (a successful ``/usage reset``
redemption, or a positive live probe) so auth.json stops freezing credentials behind a stale
``last_error_reset_at``.
When *access_token* is given, only the matching entry is cleared; otherwise every rate-limited
entry clears (a redeemed banked reset restores the whole account, and any entry that is
genuinely still exhausted just re-freezes with fresh metadata on its next 429).
"""
cleared = 0
try:
with _auth_store_lock():
auth_store = _load_auth_store()
entries = _pool_entries(auth_store, "openai-codex")
if entries is None:
return 0
for entry in entries:
if not isinstance(entry, dict):
continue
if entry.get("last_status") != "exhausted":
continue
if access_token and str(entry.get("access_token") or "") != access_token:
continue
if not _is_codex_rate_limit_shaped(
entry.get("last_error_code"),
entry.get("last_error_reason"),
entry.get("last_error_message"),
):
continue
_clear_pool_entry_status(entry)
cleared += 1
if cleared:
_save_auth_store(auth_store)
except Exception:
logger.debug("Failed to clear Codex pool quota cooldowns", exc_info=True)
return cleared
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
"""Return metadata for a pool-only Codex credential in quota cooldown."""
def _parse_reset_at(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
numeric = None
if numeric is not None:
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
entries = _pool_entries(auth_store, "openai-codex")
if entries is None:
return None
now = time.time()
for entry in entries:
if not isinstance(entry, dict):
continue
token = entry.get("access_token")
if not _nonempty_str(token):
continue
if entry.get("last_status") != "exhausted":
continue
if not _is_codex_rate_limit_shaped(
entry.get("last_error_code"),
entry.get("last_error_reason"),
entry.get("last_error_message"),
):
continue
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
if reset_at is not None and reset_at <= now:
continue
return {
"label": entry.get("label"),
"last_refresh": entry.get("last_refresh"),
"reset_at": reset_at,
"reason": entry.get("last_error_reason"),
"message": entry.get("last_error_message"),
"access_token": token.strip(),
"base_url": entry.get("base_url"),
}
except Exception:
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
return None
def _pool_entries(auth_store: Dict[str, Any], provider_id: str) -> Optional[List[Any]]:
"""``auth_store["credential_pool"][provider_id]`` when it is a list, else None."""
pool = auth_store.get("credential_pool")
entries = pool.get(provider_id) if isinstance(pool, dict) else None
return entries if isinstance(entries, list) else None
def _pool_codex_access_token() -> str:
"""Return the most-recent usable access_token from the openai-codex pool.
Used as a fallback by ``resolve_codex_runtime_credentials`` when the singleton has no creds.
Reads ``credential_pool.openai-codex`` entries directly from auth.json and picks the first non-
empty access_token, preferring entries that are not currently in an exhaustion cooldown.
"""
try:
with _auth_store_lock():
auth_store = _load_auth_store()
entries = _pool_entries(auth_store, "openai-codex")
if entries is None:
return ""
def _entry_usable(entry: Dict[str, Any]) -> bool:
if not isinstance(entry, dict):
return False
token = entry.get("access_token")
if not _nonempty_str(token):
return False
# Skip entries currently in an exhaustion cooldown window.
reset_at = entry.get("last_error_reset_at")
return not (isinstance(reset_at, (int, float)) and reset_at > time.time())
for entry in entries:
if _entry_usable(entry):
return str(entry.get("access_token", "")).strip()
except Exception:
logger.debug("Codex pool fallback lookup failed", exc_info=True)
return ""
# =============================================================================
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
# =============================================================================
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Return usable xAI OAuth state from provider state or credential pool."""
state = _load_provider_state(auth_store, "xai-oauth")
tokens = state.get("tokens") if isinstance(state, dict) else None
if isinstance(tokens, dict):
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if access_token and refresh_token:
return state
credential_pool = auth_store.get("credential_pool")
entries = (
credential_pool.get("xai-oauth")
if isinstance(credential_pool, dict)
else None
)
if isinstance(entries, list):
for entry in entries:
if not isinstance(entry, dict):
continue
access_token = str(entry.get("access_token", "") or "").strip()
refresh_token = str(entry.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
continue
merged = dict(state or {})
merged["tokens"] = {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": str(entry.get("token_type") or "Bearer"),
}
if entry.get("last_refresh"):
merged["last_refresh"] = entry.get("last_refresh")
merged.setdefault("auth_mode", "oauth_pkce")
return merged
return state if isinstance(state, dict) else None
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
tokens = state.get("tokens") if isinstance(state, dict) else None
return (
isinstance(tokens, dict)
and bool(str(tokens.get("access_token", "") or "").strip())
and bool(str(tokens.get("refresh_token", "") or "").strip())
)
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
auth_store = _load_auth_store_maybe_locked(_lock)
state = _xai_oauth_state_from_store(auth_store)
if not _xai_oauth_state_has_usable_tokens(state):
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
if _xai_oauth_state_has_usable_tokens(global_state):
state = global_state
if not state:
raise _xai_err(
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
"xai_auth_missing", relogin=True,
)
tokens = state.get("tokens")
if not isinstance(tokens, dict):
raise _xai_err(
"xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.",
"xai_auth_invalid_shape", relogin=True,
)
access_token = str(tokens.get("access_token", "") or "").strip()
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
if not access_token:
raise _xai_err(
"xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.",
"xai_auth_missing_access_token", relogin=True,
)
if not refresh_token:
raise _xai_err(
"xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.",
"xai_auth_missing_refresh_token", relogin=True,
)
return {
"tokens": tokens,
"last_refresh": state.get("last_refresh"),
"discovery": state.get("discovery") or {},
"redirect_uri": state.get("redirect_uri"),
}
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
Best-effort write-through for the multi-profile rotation hazard (#43589): xAI rotates the
refresh_token on every refresh, so when a profile session refreshes a grant it resolved from the
root fallback, the rotated chain must land back in root.
Only updates ``providers.xai-oauth`` in the root store; never touches the profile store (the
caller already saved that). Swallows all errors — a failed write-through degrades to the pre-
existing behavior (root stale), it must never break the profile's own successful save.
"""
global_path = _global_auth_file_path()
if global_path is None:
# Classic mode (profile == root); the profile save already hit root.
return
# Seat belt: under pytest, refuse to write the real user's
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
# (mirrors the read-side guard in _load_global_auth_store). Uses the
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
if 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):
return
except Exception:
return
try:
_persist_provider_state_to_store(
"xai-oauth",
state,
global_path,
set_active=False,
)
except Exception as exc: # pragma: no cover - best effort
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
def _save_xai_oauth_tokens(
tokens: Dict[str, Any],
*,
discovery: Optional[Dict[str, Any]] = None,
redirect_uri: str = "",
last_refresh: Optional[str] = None,
auth_mode: str = "oauth_device_code",
set_active: bool = True,
) -> None:
"""Persist xAI OAuth tokens into the auth store.
When *set_active* is True (default), also promote ``xai-oauth`` to ``active_provider`` —
appropriate for intentional model/auth login. Pass ``set_active=False`` for side-tool credential
bootstrap (TTS/setup, tools config, dashboard token save, token refresh) so inference routing is
unchanged.
"""
if last_refresh is None:
last_refresh = _utc_now_z()
with _auth_store_lock():
auth_store = _load_auth_store()
# A profile that lacks its own xai-oauth block is reading the root
# grant through _load_provider_state's fallback. When such a profile
# refreshes the (rotating) grant, we must write the rotated chain back
# to root too, or root is left holding a revoked refresh token (#43589).
# #74339: the old key-presence check (_profile_has_own_xai_oauth_state)
# decided write-through based on whether the profile had a
# providers.xai-oauth key BEFORE the save — but _store_provider_state
# unconditionally creates that key below. Use
# _load_provider_state_with_source to learn where the grant was
# resolved from and write back only to that source.
state, source_path = _load_provider_state_with_source(
auth_store, "xai-oauth"
)
if state is None:
state = {}
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = auth_mode
if discovery:
state["discovery"] = discovery
if redirect_uri:
state["redirect_uri"] = redirect_uri
global_root = _global_auth_file_path()
is_from_root = bool(
source_path is not None
and global_root is not None
and _same_path(source_path, global_root)
)
if is_from_root:
# Grant was resolved from root — write back to root only.
# Do NOT call _store_provider_state on the profile auth_store
# (it would create a shadowing providers.xai-oauth key that
# disables write-through on the next refresh — #74339).
_write_through_xai_oauth_to_global_root(state)
else:
# Profile genuinely owns this — write to profile store.
_store_provider_state(
auth_store, "xai-oauth", state, set_active=set_active
)
_save_auth_store(auth_store)
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
if not isinstance(access_token, str) or "." not in access_token:
return False
try:
parts = access_token.split(".")
if len(parts) < 2:
return False
payload_b64 = parts[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8"))
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return False
return float(exp) <= (time.time() + max(0, int(skew_seconds)))
except Exception:
return False
def _xai_proactive_refresh_skew_seconds(access_token: str) -> int:
"""How far before JWT ``exp`` to proactively refresh xAI OAuth tokens.
SuperGrok sessions ship multi-hour tokens where the gateway-oriented hour-long skew makes sense,
but device-code logins often return ~15-minute JWTs; the full skew would force a refresh on
every credential resolution, burning single-use refresh tokens and racing concurrent callers
into ``invalid_grant`` quarantine.
"""
max_skew = XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS
if not isinstance(access_token, str) or "." not in access_token:
return max_skew
try:
parts = access_token.split(".")
if len(parts) < 2:
return max_skew
payload_b64 = parts[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8"))
exp = payload.get("exp")
if not isinstance(exp, (int, float)):
return max_skew
remaining = float(exp) - time.time()
if remaining <= 0:
return max_skew
if remaining <= 45 * 60:
return min(120, max_skew)
return max_skew
except Exception:
return max_skew
def _is_xai_origin_host(host: str) -> bool:
"""``x.ai`` is the bare apex, so an exact match or any ``.x.ai`` suffix is accepted."""
return host == "x.ai" or host.endswith(".x.ai")
def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str:
"""Refuse any OIDC discovery endpoint that isn't HTTPS on the xAI origin.
The discovery result is cached in auth.json, so a single MITM at login could plant a malicious
``token_endpoint`` that receives the refresh_token forever. Pinning scheme + host (RFC 8414 §2:
HTTPS issuer, same-origin token_endpoint) removes that persistence; ``x.ai`` is the bare apex,
so an exact match or any ``.x.ai`` suffix is accepted.
"""
parsed = urlparse(url)
if parsed.scheme != "https":
raise _xai_err(
f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.",
"xai_discovery_invalid",
)
host = (parsed.hostname or "").lower()
if not host:
raise _xai_err(
f"xAI OIDC discovery {field} is missing a hostname: {url!r}.",
"xai_discovery_invalid",
)
if not _is_xai_origin_host(host):
raise _xai_err(
f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin "
f"(expected x.ai or a *.x.ai subdomain). Refusing to use a cached "
f"endpoint that may have been substituted by a MITM during initial "
f"discovery; re-authenticate with `hermes model` to re-fetch.",
"xai_discovery_invalid",
)
return url
def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str:
"""Refuse a non-xAI base_url for the OAuth-authenticated inference path.
Pin the inference origin to ``api.x.ai`` (or any ``*.x.ai`` subdomain xAI may add). On
rejection, fall back to the default and log a warning rather than raise — a bad env var should
not deadlock authentication, but it should also never leak the bearer.
``value`` is the already-stripped, trailing-slash-trimmed candidate from env. Empty input
returns ``fallback`` unchanged.
"""
candidate = (value or "").strip().rstrip("/")
if not candidate:
return fallback
try:
parsed = urlparse(candidate)
except Exception:
logger.warning(
"Ignoring malformed xAI base_url override %r; using %s instead.",
candidate, fallback,
)
return fallback
if parsed.scheme != "https":
logger.warning(
"Refusing non-HTTPS xAI base_url override %r (xai-oauth bearer would "
"be sent in cleartext); falling back to %s.",
candidate, fallback,
)
return fallback
host = (parsed.hostname or "").lower()
if not host:
logger.warning(
"Ignoring xAI base_url override %r with no hostname; using %s instead.",
candidate, fallback,
)
return fallback
if not _is_xai_origin_host(host):
logger.warning(
"Refusing xAI base_url override %r — host %r is not on the xAI origin "
"(expected x.ai or a *.x.ai subdomain). The xai-oauth bearer is only "
"valid against xAI's inference API; sending it elsewhere would leak "
"the credential. Falling back to %s.",
candidate, host, fallback,
)
return fallback
return candidate
def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]:
try:
response = httpx.get(
XAI_OAUTH_DISCOVERY_URL,
headers={"Accept": "application/json"},
timeout=timeout_seconds,
)
except Exception as exc:
raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc
if response.status_code != 200:
raise _xai_err(
f"xAI OIDC discovery returned status {response.status_code}.",
"xai_discovery_failed",
)
try:
payload = response.json()
except Exception as exc:
raise _xai_err(
f"xAI OIDC discovery returned invalid JSON: {exc}",
"xai_discovery_invalid_json",
) from exc
if not isinstance(payload, dict):
raise _xai_err(
"xAI OIDC discovery response was not a JSON object.",
"xai_discovery_incomplete",
)
authorization_endpoint = str(payload.get("authorization_endpoint", "") or "").strip()
token_endpoint = str(payload.get("token_endpoint", "") or "").strip()
if not authorization_endpoint or not token_endpoint:
raise _xai_err(
"xAI OIDC discovery response was missing required endpoints.",
"xai_discovery_incomplete",
)
_xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint")
_xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint")
return {
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
}
def _xai_tokens_from_payload(payload: Dict[str, Any], access_token: str, fallback_refresh: str) -> Dict[str, Any]:
"""Token block persisted for xAI OAuth; falls back to *fallback_refresh* when none is rotated in."""
return {
"access_token": access_token,
"refresh_token": str(payload.get("refresh_token") or fallback_refresh).strip(),
"id_token": str(payload.get("id_token") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
}
def refresh_xai_oauth_pure(
access_token: str,
refresh_token: str,
*,
token_endpoint: str = "",
timeout_seconds: float = 20.0,
) -> Dict[str, Any]:
del access_token
if not _nonempty_str(refresh_token):
raise _xai_err(
"xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.",
"xai_auth_missing_refresh_token", relogin=True,
)
endpoint = token_endpoint.strip() or _xai_oauth_discovery(timeout_seconds)["token_endpoint"]
# Re-validate cached endpoints on the refresh hot path: an auth.json
# written by an older Hermes (or hand-edited) may carry a non-xAI
# token_endpoint that would receive every future refresh_token in
# plaintext if we trusted it blindly. Cheap suffix check; fast-fail
# with a clear error so the user can re-run `hermes model` to refetch.
_xai_validate_oauth_endpoint(endpoint, field="token_endpoint")
timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
response = client.post(
endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "refresh_token",
"client_id": XAI_OAUTH_CLIENT_ID,
"refresh_token": refresh_token,
},
)
if response.status_code != 200:
detail = response.text.strip()
# ``403`` from xAI's token endpoint is almost always a tier /
# entitlement gate (the OAuth grant exists but the account isn't
# on the allowlist for API access). Re-running ``hermes model``
# won't fix that — surface a separate error code so
# ``format_auth_error`` doesn't append a misleading
# re-authenticate hint, and point users at the ``XAI_API_KEY``
# fallback. See #26847.
if response.status_code == 403:
raise _xai_err(
"xAI token refresh failed with HTTP 403."
+ (f" Response: {detail}" if detail else "")
+ " This OAuth account is not authorized for xAI API"
" access — xAI may be restricting API/OAuth use to"
" specific SuperGrok tiers despite the in-app"
" subscription being active. Re-logging in won't"
" change that; set ``XAI_API_KEY`` and switch to"
" ``provider: xai`` (API-key path) if available, or"
" upgrade your subscription at https://x.ai/grok.",
"xai_oauth_tier_denied", relogin=False,
)
raise _xai_err(
"xAI token refresh failed."
+ (f" Response: {detail}" if detail else ""),
"xai_refresh_failed", relogin=response.status_code in {400, 401},
)
payload, refreshed_access = _refresh_payload_access_token(
response,
provider="xai-oauth",
invalid_json=("xAI token refresh returned invalid JSON: {exc}", "xai_refresh_invalid_json"),
invalid_json_relogin=False,
strict_str=False,
invalid_response=(
"xAI token refresh response was not a JSON object.",
"xai_refresh_invalid_response",
),
missing_access=(
"xAI token refresh response was missing access_token.",
"xai_refresh_missing_access_token",
),
)
return {
**_xai_tokens_from_payload(payload, refreshed_access, refresh_token),
"last_refresh": _utc_now_z(),
}
def _refresh_xai_oauth_tokens(
tokens: Dict[str, Any],
*,
token_endpoint: str,
redirect_uri: str = "",
timeout_seconds: float,
) -> Dict[str, Any]:
# Re-persist whatever auth_mode is already stored (legacy pre-device-code
# logins may still carry ``oauth_pkce``): the refresh hot path must not
# relabel how the grant was originally obtained.
try:
state = _load_provider_state(_load_auth_store(), "xai-oauth") or {}
auth_mode = str(state.get("auth_mode") or "oauth_device_code")
except Exception:
auth_mode = "oauth_device_code"
refreshed = refresh_xai_oauth_pure(
str(tokens.get("access_token", "") or ""),
str(tokens.get("refresh_token", "") or ""),
token_endpoint=token_endpoint,
timeout_seconds=timeout_seconds,
)
updated_tokens = dict(tokens)
updated_tokens["access_token"] = refreshed["access_token"]
updated_tokens["refresh_token"] = refreshed["refresh_token"]
if refreshed.get("id_token"):
updated_tokens["id_token"] = refreshed["id_token"]
if refreshed.get("expires_in") is not None:
updated_tokens["expires_in"] = refreshed["expires_in"]
if refreshed.get("token_type"):
updated_tokens["token_type"] = refreshed["token_type"]
_save_xai_oauth_tokens(
updated_tokens,
discovery={"token_endpoint": token_endpoint},
redirect_uri=redirect_uri,
last_refresh=refreshed["last_refresh"],
auth_mode=auth_mode,
# Refresh must not flip active_provider — TTS/side tools can refresh
# xAI tokens while chat still routes through another provider.
set_active=False,
)
return updated_tokens
def _quarantine_xai_oauth_tokens(exc: AuthError) -> None:
"""Clear dead xAI tokens from auth.json after a terminal refresh failure.
Terminal = HTTP 400/401/403 (invalid_grant, token revoked). Subsequent sessions then fail fast
without a network retry. Mirrors credential_pool.py quarantine. Best-effort: persistence
failures are logged and swallowed (caller re-raises the original error regardless).
"""
try:
_q_store = _load_auth_store()
_q_state = _load_provider_state(_q_store, "xai-oauth") or {}
_q_tokens = dict(_q_state.get("tokens") or {})
_q_tokens.pop("access_token", None)
_q_tokens.pop("refresh_token", None)
_q_state["tokens"] = _q_tokens
_q_state["last_auth_error"] = _last_auth_error_marker(
"xai-oauth", exc,
reason="runtime_refresh_failure", default_code="xai_refresh_failed",
)
_store_provider_state(_q_store, "xai-oauth", _q_state, set_active=False)
_save_auth_store(_q_store)
except Exception as _save_exc:
logger.debug(
"xAI OAuth: failed to persist quarantined state: %s", _save_exc,
)
def _xai_oauth_inference_base_url() -> str:
return _xai_validate_inference_base_url(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
fallback=DEFAULT_XAI_OAUTH_BASE_URL,
)
def resolve_xai_oauth_runtime_credentials(
*,
force_refresh: bool = False,
refresh_if_expiring: bool = True,
refresh_skew_seconds: Optional[int] = None,
) -> Dict[str, Any]:
def _view(data: Dict[str, Any]) -> tuple[Dict[str, Any], str, str, str, bool]:
tokens = dict(data["tokens"])
access_token = str(tokens.get("access_token", "") or "").strip()
discovery = dict(data.get("discovery") or {})
token_endpoint = str(discovery.get("token_endpoint", "") or "").strip()
redirect_uri = str(data.get("redirect_uri", "") or "").strip()
effective_skew = (
int(refresh_skew_seconds)
if refresh_skew_seconds is not None
else _xai_proactive_refresh_skew_seconds(access_token)
)
should_refresh = bool(force_refresh)
if (not should_refresh) and refresh_if_expiring:
should_refresh = _xai_access_token_is_expiring(access_token, effective_skew)
return tokens, access_token, token_endpoint, redirect_uri, should_refresh
data = _read_xai_oauth_tokens()
refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20)
tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data)
if should_refresh:
with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
data = _read_xai_oauth_tokens(_lock=False)
tokens, access_token, token_endpoint, redirect_uri, should_refresh = _view(data)
if should_refresh:
if not token_endpoint:
token_endpoint = _xai_oauth_discovery(refresh_timeout_seconds)["token_endpoint"]
try:
tokens = _refresh_xai_oauth_tokens(
tokens,
token_endpoint=token_endpoint,
redirect_uri=redirect_uri,
timeout_seconds=refresh_timeout_seconds,
)
access_token = str(tokens.get("access_token", "") or "").strip()
except AuthError as exc:
if _is_terminal_xai_oauth_refresh_error(exc):
_quarantine_xai_oauth_tokens(exc)
raise
base_url = _xai_oauth_inference_base_url()
return {
"provider": "xai-oauth",
"base_url": base_url,
"api_key": access_token,
"source": "hermes-auth-store",
"last_refresh": data.get("last_refresh"),
# Display/telemetry only. Device-code is the only supported xAI OAuth
# flow, so report it unconditionally — auth.json may still carry a
# legacy ``oauth_pkce`` label, which the refresh path preserves as-is.
"auth_mode": "oauth_device_code",
}
# =============================================================================
# TLS verification helper
# =============================================================================
def _default_verify() -> bool | ssl.SSLContext:
"""Platform-aware default SSL verify for httpx clients.
On macOS with Homebrew Python, the system OpenSSL cannot locate the system trust store and valid
public certs fail verification. When certifi is importable we pin its bundle explicitly;
elsewhere we defer to httpx's built-in default (certifi via its own dependency). Mirrors the
weixin fix in 3a0ec1d93.
"""
if sys.platform == "darwin":
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
pass
return True
def _resolve_verify(
*,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
auth_state: Optional[Dict[str, Any]] = None,
) -> bool | ssl.SSLContext:
tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {}
tls_state = tls_state if isinstance(tls_state, dict) else {}
effective_insecure = (
is_truthy_value(insecure, default=False) if insecure is not None
else is_truthy_value(tls_state.get("insecure", False), default=False)
)
effective_ca = (
ca_bundle
or tls_state.get("ca_bundle")
or os.getenv("HERMES_CA_BUNDLE")
or os.getenv("SSL_CERT_FILE")
or os.getenv("REQUESTS_CA_BUNDLE")
)
if effective_insecure:
return False
if effective_ca:
ca_path = str(effective_ca)
if not os.path.isfile(ca_path):
logger.warning(
"CA bundle path does not exist: %s — falling back to default certificates",
ca_path,
)
return _default_verify()
return ssl.create_default_context(cafile=ca_path)
return _default_verify()
# =============================================================================
# OAuth Device Code Flow — generic, parameterized by provider
# =============================================================================
def _request_device_code(
client: httpx.Client,
portal_base_url: str,
client_id: str,
scope: Optional[str],
) -> Dict[str, Any]:
"""POST to the device code endpoint. Returns device_code, user_code, etc."""
response = client.post(
f"{portal_base_url}/api/oauth/device/code",
data={
"client_id": client_id,
**({"scope": scope} if scope else {}),
},
)
response.raise_for_status()
data = response.json()
required_fields = [
"device_code", "user_code", "verification_uri",
"verification_uri_complete", "expires_in", "interval",
]
missing = [f for f in required_fields if f not in data]
if missing:
raise ValueError(f"Device code response missing fields: {', '.join(missing)}")
return data
def _nous_device_auth_timeout_message(portal_base_url: str) -> str:
"""Actionable timeout text for Nous device-code login failures.
A bare "timed out" gives the user nothing to act on; the usual cause is Portal sign-in failing
in the opened browser tab, so point at the Portal login page and the retry command.
"""
portal = (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
return (
"Timed out waiting for device authorization.\n"
" Portal sign-in is required before the device code can be approved.\n"
" If the browser showed a CAPTCHA / 'You did not pass CAPTCHA' error,\n"
" finish signing in at the Portal in a normal browser tab, then retry:\n"
" hermes portal\n"
f" Portal login: {portal}/login"
)
def _print_device_code_instructions(
verification_url: str,
user_code: str,
*,
open_browser: bool,
failure_dash: str = "--",
swallow_open_errors: bool = False,
) -> None:
"""Print the shared "To continue" device-code block and optionally open the browser.
Callers decide *whether* to open (remote-session / graphical-browser gating differs per
provider); the wording of the fallback hint is parameterized so each provider keeps its
historical dash style.
"""
print()
print("To continue:")
print(f" 1. Open: {verification_url}")
print(f" 2. If prompted, enter code: {user_code}")
if not open_browser:
return
if swallow_open_errors:
try:
opened = webbrowser.open(verification_url)
except Exception:
opened = False
else:
opened = webbrowser.open(verification_url)
if opened:
print(" (Opened browser for verification)")
else:
print(f" Could not open browser automatically {failure_dash} use the URL above.")
def _poll_device_token_generic(
post: Callable[[], "httpx.Response"],
*,
expires_in: int,
poll_interval: int,
validate_success: Callable[[Dict[str, Any]], None],
on_non_json_error: Callable[["httpx.Response"], Exception],
on_error: Callable[["httpx.Response", Dict[str, Any]], Exception],
on_timeout: Callable[[], Exception],
) -> Dict[str, Any]:
"""RFC 8628 device-code polling loop shared by the Nous and xAI flows.
``authorization_pending`` sleeps and retries; ``slow_down`` grows the interval by 1s (cap 30s).
Every other error, a non-JSON error body, and the deadline are turned into provider-specific
exceptions by the supplied factories so each caller keeps its exact error contract.
"""
deadline = time.monotonic() + max(1, expires_in)
current_interval = poll_interval
while time.monotonic() < deadline:
response = post()
if response.status_code == 200:
payload = response.json()
validate_success(payload)
return payload
try:
error_payload = response.json()
except Exception:
response.raise_for_status()
raise on_non_json_error(response)
error_code = str(error_payload.get("error") or "")
if error_code == "authorization_pending":
time.sleep(current_interval)
continue
if error_code == "slow_down":
current_interval = min(current_interval + 1, 30)
time.sleep(current_interval)
continue
raise on_error(response, error_payload)
raise on_timeout()
def _poll_for_token(
client: httpx.Client,
portal_base_url: str,
client_id: str,
device_code: str,
expires_in: int,
poll_interval: int,
) -> Dict[str, Any]:
"""Poll the Nous token endpoint until the user approves or the code expires."""
def _validate(payload: Dict[str, Any]) -> None:
if "access_token" not in payload:
raise ValueError("Token response did not include access_token")
def _error(_response, error_payload) -> Exception:
error_code = error_payload.get("error", "")
description = error_payload.get("error_description") or "Unknown authentication error"
return RuntimeError(f"{error_code}: {description}")
return _poll_device_token_generic(
lambda: client.post(
f"{portal_base_url}/api/oauth/token",
data={
"grant_type": DEVICE_CODE_GRANT_TYPE,
"client_id": client_id,
"device_code": device_code,
},
),
expires_in=expires_in,
poll_interval=max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)),
validate_success=_validate,
on_non_json_error=lambda _r: RuntimeError("Token endpoint returned a non-JSON error response"),
on_error=_error,
# Enriched at the SOURCE so every caller inherits the guidance:
# the CLI login (_nous_device_code_login) and the dashboard/desktop
# poller (web_server._nous_poller, which surfaces str(e) to the UI).
on_timeout=lambda: TimeoutError(_nous_device_auth_timeout_message(portal_base_url)),
)
# =============================================================================
# Nous Portal — token refresh and model discovery
# =============================================================================
# -----------------------------------------------------------------------------
# Shared Nous token store — lets OAuth credentials persist across profiles
# so a new `hermes --profile <name> auth add nous --type oauth` can one-tap
# import instead of running the full device-code flow every time.
#
# File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to
# ``<hermes-root>/shared/nous_auth.json`` where ``<hermes-root>`` is what
# ``get_default_hermes_root()`` returns — ``~/.hermes`` on Linux/macOS,
# ``%LOCALAPPDATA%\hermes`` on native Windows, or the Docker/custom root.
# It is OUTSIDE any named profile's HERMES_HOME so named profiles (which
# typically live under ``<hermes-root>/profiles/<name>/``) all see the
# same file.
#
# Written on successful login and on every runtime refresh so the stored
# refresh_token stays current even if one profile refreshes and rotates it.
# If ever the stored refresh_token does go stale server-side, import fails
# gracefully and the user falls back to the normal device-code flow.
# -----------------------------------------------------------------------------
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
_nous_shared_lock_holder = threading.local()
def _nous_shared_auth_dir() -> Path:
"""Resolve the directory that holds the shared Nous token store.
Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it. Defaults to
``<hermes-root>/shared/`` (``~/.hermes/shared/`` on POSIX, ``%LOCALAPPDATA%\\hermes\\shared\\``
on Windows), outside any named profile so all profiles under one root share the store.
"""
override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
if override:
return Path(override).expanduser()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "shared"
def _nous_shared_store_path() -> Path:
path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
# Seat belt: if pytest is running and this resolves to a path under the
# real user's Hermes root, refuse rather than silently corrupt cross-profile
# state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest
# does not do this automatically — mirror the _auth_file_path() guard
# so forgetting to set it fails loudly instead of writing to the real
# shared store).
if os.environ.get("PYTEST_CURRENT_TEST"):
from hermes_constants import get_default_hermes_root
real_home_shared = (
get_default_hermes_root() / "shared" / NOUS_SHARED_STORE_FILENAME
).resolve(strict=False)
try:
resolved = path.resolve(strict=False)
except Exception:
resolved = path
if resolved == real_home_shared:
raise RuntimeError(
f"Refusing to touch real user shared Nous auth store during test run: "
f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture."
)
return path
@contextmanager
def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
"""Cross-profile lock for the shared Nous OAuth store.
Lock ordering invariant: if both this and ``_auth_store_lock`` need to be held, acquire
``_auth_store_lock`` FIRST. All runtime refresh paths follow this order.
"""
try:
lock_path = _nous_shared_store_path().with_suffix(".lock")
except RuntimeError:
# No HERMES_HOME yet (pre-setup): fall through without locking.
yield
return
with _file_lock(
lock_path,
_nous_shared_lock_holder,
timeout_seconds,
"Timed out waiting for shared Nous auth lock",
):
yield
# OAuth fields mirrored between a profile's Nous state and the shared cross-profile store.
_NOUS_SHARED_STATE_KEYS = (
"access_token",
"refresh_token",
"token_type",
"scope",
"client_id",
"portal_base_url",
"inference_base_url",
"obtained_at",
"expires_at",
)
def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool:
"""Copy fresher shared OAuth tokens into a profile-local Nous state."""
shared = _read_shared_nous_state()
if not shared:
return False
shared_refresh = shared.get("refresh_token")
if not _nonempty_str(shared_refresh):
return False
local_refresh = state.get("refresh_token")
shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0
local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0
refresh_changed = shared_refresh.strip() != str(local_refresh or "").strip()
fresher_access = shared_access_exp > local_access_exp
if not refresh_changed and not fresher_access:
return False
for key in _NOUS_SHARED_STATE_KEYS:
value = shared.get(key)
if value not in {None, ""}:
state[key] = value
return True
def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]:
"""The defaulted OAuth core (tokens + routing + expiry) shared across profiles."""
return {
"access_token": src.get("access_token"),
"refresh_token": src.get("refresh_token"),
"token_type": src.get("token_type") or "Bearer",
"scope": src.get("scope") or DEFAULT_NOUS_SCOPE,
"client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
"inference_base_url": src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
"obtained_at": src.get("obtained_at"),
"expires_at": src.get("expires_at"),
}
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
"""Persist a minimal copy of the Nous OAuth state to the shared store.
Best-effort: any failure is swallowed after logging. The shared store is a convenience layer;
the per-profile auth.json remains the source of truth.
"""
refresh_token = state.get("refresh_token")
access_token = state.get("access_token")
# No refresh_token = nothing worth sharing across profiles
if not (_nonempty_str(refresh_token) and _nonempty_str(access_token)):
return
shared = {
"_schema": 1,
**_nous_shared_shape(state),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
try:
with _nous_shared_store_lock():
path = _nous_shared_store_path()
_write_private_file_atomic(
path, json.dumps(shared, indent=2, sort_keys=True), replace=os.replace,
)
_oauth_trace(
"nous_shared_store_written",
path=str(path),
refresh_token_fp=_token_fingerprint(refresh_token),
)
except Exception as exc:
logger.debug("Failed to write shared Nous auth store: %s", exc)
def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
"""Return the shared Nous OAuth state if present and well-formed.
Returns ``None`` when the file is missing, unreadable, malformed, or lacks required fields;
callers treat that as "no shared credentials, fall through to device-code".
"""
try:
path = _nous_shared_store_path()
except RuntimeError:
# Test seat belt tripped — treat as missing
return None
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
return None
if not isinstance(payload, dict):
return None
if not (_nonempty_str(payload.get("refresh_token")) and _nonempty_str(payload.get("access_token"))):
return None
return payload
def _clear_shared_nous_state(reason: str) -> None:
"""Remove the shared Nous OAuth store after a terminal token failure."""
try:
with _nous_shared_store_lock():
path = _nous_shared_store_path()
try:
path.unlink()
except FileNotFoundError:
pass
_oauth_trace("nous_shared_store_cleared", reason=reason)
except Exception as exc:
logger.debug("Failed to clear shared Nous auth store: %s", exc)
# Error codes per provider for which retrying the SAME refresh token cannot succeed.
# ``*_refresh_failed`` covers HTTP 400/401/403 from the token endpoint (invalid_grant, token
# revoked, refresh_token_reused); ``*_auth_missing_refresh_token`` means the pool entry has no
# refresh token at all. All must also carry ``relogin_required=True``; transient failures
# (429, 5xx) do not.
_OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"})
_TERMINAL_REFRESH_ERROR_CODES: Dict[str, FrozenSet[str]] = {
"nous": _OAUTH_GRANT_DEAD_CODES,
"xai-oauth": frozenset({"xai_refresh_failed", "xai_auth_missing_refresh_token"}),
"openai-codex": _OAUTH_GRANT_DEAD_CODES | {"codex_refresh_failed", "codex_auth_missing_refresh_token"},
}
def _is_terminal_refresh_error(exc: Exception, provider: str) -> bool:
"""True when retrying the same *provider* refresh token cannot succeed."""
return (
isinstance(exc, AuthError)
and exc.provider == provider
and exc.code in _TERMINAL_REFRESH_ERROR_CODES[provider]
and bool(exc.relogin_required)
)
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 _quarantine_nous_oauth_state(
state: Dict[str, Any],
error: AuthError,
*,
reason: str,
) -> None:
"""Keep routing metadata but remove dead OAuth material so it is not replayed."""
# Forensic logging BEFORE we clear the token material. A hosted agent
# can take a terminal invalid_grant and get quarantined here silently: the
# only downstream signal is a "No access token found" WARNING once the pool
# is already empty, which is too late to root-cause. A managed log drain may
# be WARNING-only, so this MUST be logger.warning (INFO never reaches it).
#
# Redaction safety: emit ONLY the 12-char SHA-256 hex prefix of the refresh
# token (correlates to NAS's refreshTokenHash without leaking the secret) plus
# sizes/booleans. NEVER pass a raw token/agent_key into the log call — Hermes
# has a known bug class where credential-shaped literals get corrupted in logs.
forensic: Dict[str, Any] = {
"reason": reason,
"error_code": error.code,
# No session_id field exists on Nous state; provenance is client_id +
# agent_key_id (both non-secret routing identifiers).
"client_id": state.get("client_id"),
"agent_key_id": state.get("agent_key_id"),
"refresh_token_fp": _token_fingerprint(state.get("refresh_token")),
}
# On-disk integrity of the auth store at the moment of quarantine.
try:
auth_path = _auth_file_path()
forensic["auth_json_path"] = str(auth_path)
try:
st = os.stat(auth_path)
forensic["auth_json_size"] = st.st_size
forensic["auth_json_mtime"] = st.st_mtime
forensic["auth_json_exists"] = True
except FileNotFoundError:
forensic["auth_json_exists"] = False
except Exception as exc: # pragma: no cover - never let logging break quarantine
forensic["auth_json_stat_error"] = repr(exc)
# Was the token already past its own expiry when it was rejected?
already_expired: Optional[bool] = None
expires_at_raw = state.get("expires_at")
if isinstance(expires_at_raw, str) and expires_at_raw:
try:
parsed = datetime.fromisoformat(expires_at_raw)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
already_expired = parsed < datetime.now(timezone.utc)
except ValueError:
already_expired = None
forensic["token_already_expired"] = already_expired
logger.warning(
"Nous OAuth state quarantined (terminal auth death): %s",
json.dumps(forensic, sort_keys=True, ensure_ascii=False),
)
for key in (*_FLAT_OAUTH_TOKEN_KEYS, *_NOUS_EMPTY_AGENT_KEY_FIELDS):
state.pop(key, None)
state["last_auth_error"] = _last_auth_error_marker("nous", error, reason=reason)
_clear_shared_nous_state(reason)
invalidate_nous_auth_status_cache()
def _quarantine_nous_pool_entries(
auth_store: Dict[str, Any],
error: AuthError,
*,
reason: str,
) -> bool:
"""Remove singleton-seeded Nous pool entries that contain dead OAuth state."""
entries = _pool_entries(auth_store, "nous")
if entries is None:
return False
pool = auth_store["credential_pool"]
retained = []
removed = False
singleton_sources = {NOUS_DEVICE_CODE_SOURCE, f"manual:{NOUS_DEVICE_CODE_SOURCE}"}
for entry in entries:
if isinstance(entry, dict) and entry.get("source") in singleton_sources:
removed = True
continue
retained.append(entry)
if removed:
pool["nous"] = retained
_oauth_trace(
"nous_pool_device_code_quarantined",
reason=reason,
error_code=error.code,
)
return removed
def _try_import_shared_nous_state(
*,
timeout_seconds: float = 15.0,
) -> Optional[Dict[str, Any]]:
"""Attempt to rehydrate Nous OAuth state from the shared store.
Runs a forced refresh with the stored refresh_token to mint a fresh inference JWT scoped to
this profile and returns the auth_state dict ready for ``persist_nous_credentials()``.
Returns ``None`` on any failure (expired token, portal unreachable) so the caller falls
through to the normal device-code flow.
"""
try:
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
shared = _read_shared_nous_state()
if not shared:
return None
# Build a full state dict so refresh_nous_oauth_from_state has every
# field it needs. force_refresh=True gets us a fresh access_token
# for this profile.
state: Dict[str, Any] = {
**_nous_shared_shape(shared),
"agent_key": None,
"agent_key_expires_at": None,
"tls": {"insecure": False, "ca_bundle": None},
}
def _persist_shared_refresh(updated_state: Dict[str, Any], _reason: str) -> None:
_write_shared_nous_state(updated_state)
refreshed = refresh_nous_oauth_from_state(
state,
timeout_seconds=timeout_seconds,
force_refresh=True,
on_state_update=_persist_shared_refresh,
)
_write_shared_nous_state(refreshed)
except AuthError as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
error_code=getattr(exc, "code", None),
)
if _is_terminal_nous_refresh_error(exc):
_clear_shared_nous_state("shared_import_terminal_refresh_failure")
logger.debug("Shared Nous import failed: %s", exc)
return None
except Exception as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
)
logger.debug("Shared Nous import failed: %s", exc)
return None
return refreshed
def _refresh_access_token(
*,
client: httpx.Client,
portal_base_url: str,
client_id: str,
refresh_token: str,
) -> Dict[str, Any]:
response = client.post(
f"{portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": refresh_token},
data={
"grant_type": "refresh_token",
"client_id": client_id,
},
)
if response.status_code == 200:
payload = response.json()
if "access_token" not in payload:
raise _nous_err("Refresh response missing access_token", "invalid_token", relogin=True)
return payload
try:
error_payload = response.json()
except Exception as exc:
raise _nous_err("Refresh token exchange failed", relogin=True) from exc
code = str(error_payload.get("error", "invalid_grant"))
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"}
# Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
# server and surface an actionable message. This fires when an external
# process (health-check script, monitoring tool, custom self-heal hook)
# called POST /api/oauth/token with Hermes's refresh_token without
# persisting the rotated token back to auth.json — the server then
# retires the original RT, Hermes's next refresh uses it, and the whole
# session chain gets revoked as a token-theft signal (#15099).
lowered = description.lower()
if code == "refresh_token_reused" or "reuse" in lowered or "reuse detected" in lowered:
description = (
"Nous Portal detected refresh-token reuse and revoked this session.\n"
"This usually means an external process (monitoring script, "
"custom self-heal hook, or another Hermes install sharing "
"~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
"refresh token without persisting the rotated token back.\n"
"Nous refresh tokens are single-use — only Hermes may call the "
"refresh endpoint. For health checks, use `hermes auth status` "
"instead.\n"
"Re-authenticate with: hermes auth add nous"
)
relogin = True
raise _nous_err(description, code, relogin=relogin)
def _refresh_nous_or_quarantine(
*,
client: httpx.Client,
auth_store: Dict[str, Any],
state: Dict[str, Any],
portal_base_url: str,
client_id: str,
refresh_token: str,
reason: str,
persist: Callable[[], None],
) -> Dict[str, Any]:
"""Redeem the Nous refresh token; on a terminal failure quarantine state + pool, persist, re-raise."""
try:
return _refresh_access_token(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
refresh_token=refresh_token,
)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(state, exc, reason=reason)
_quarantine_nous_pool_entries(auth_store, exc, reason=reason)
persist()
raise
def _apply_nous_refreshed_tokens(
state: Dict[str, Any],
refreshed: Dict[str, Any],
refresh_token: str,
*,
inference_base_url: Optional[str] = None,
) -> None:
"""Write a successful Nous token-refresh payload into *state* (tokens + expiry fields).
*inference_base_url*, when given, is the healed network-provenance URL to persist alongside
the rotated tokens (key order in auth.json is preserved from the original login shape).
"""
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
if inference_base_url is not None:
state["inference_base_url"] = inference_base_url
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = _iso_after(now, access_ttl)
def _healed_nous_inference_url(refreshed: Dict[str, Any]) -> str:
"""Validated network-provenance inference URL from a refresh payload, healed to the default.
When the Portal-returned URL is rejected by the allowlist (returns None), reset to the
production default instead of leaving a previously-persisted bad host (e.g. a stale staging
URL) in place — otherwise a poisoned auth.json keeps re-validating to None on every refresh
and silently re-uses the dead endpoint.
"""
return (
_validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
or DEFAULT_NOUS_INFERENCE_URL
)
def fetch_nous_models(
*,
inference_base_url: str,
api_key: str,
timeout_seconds: float = 15.0,
verify: bool | str = True,
) -> List[str]:
"""Fetch available model IDs from the Nous inference API."""
timeout = httpx.Timeout(timeout_seconds)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
response = client.get(
f"{inference_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
if response.status_code != 200:
description = f"/models request failed with status {response.status_code}"
try:
err = response.json()
description = str(err.get("error_description") or err.get("error") or description)
except Exception as e:
logger.debug("Could not parse error response JSON: %s", e)
raise _nous_err(description, "models_fetch_failed")
payload = response.json()
data = payload.get("data")
if not isinstance(data, list):
return []
model_ids: List[str] = []
for item in data:
if not isinstance(item, dict):
continue
model_id = item.get("id")
if _nonempty_str(model_id):
mid = model_id.strip()
# Skip Hermes models — they're not reliable for agentic tool-calling
if "hermes" in mid.lower():
continue
model_ids.append(mid)
# Sort: prefer opus > pro > haiku/flash > sonnet (sonnet is cheap/fast,
# users who want the best model should see opus first).
def _model_priority(mid: str) -> tuple:
low = mid.lower()
if "opus" in low:
return (0, mid)
if "pro" in low and "sonnet" not in low:
return (1, mid)
if "sonnet" in low:
return (3, mid)
return (2, mid)
model_ids.sort(key=_model_priority)
return list(dict.fromkeys(model_ids))
def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
key = state.get("agent_key")
if not _nonempty_str(key):
return False
return _nous_invoke_jwt_is_usable(
key,
scope=state.get("scope"),
expires_at=state.get("agent_key_expires_at"),
min_ttl_seconds=max(0, int(min_ttl_seconds)),
)
# 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
def refresh_nous_oauth_pure(
access_token: str,
refresh_token: str,
client_id: str,
portal_base_url: str,
inference_base_url: str,
*,
token_type: str = "Bearer",
scope: str = DEFAULT_NOUS_SCOPE,
obtained_at: Optional[str] = None,
expires_at: Optional[str] = None,
agent_key: Optional[str] = None,
agent_key_expires_at: Optional[str] = None,
timeout_seconds: float = 15.0,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None,
) -> Dict[str, Any]:
"""Refresh Nous OAuth state without mutating auth.json directly.
``on_state_update`` is called after a successful access-token refresh. Callers that own
persistent state can use it to save the newly rotated refresh token before later validation can
fail.
"""
state: Dict[str, Any] = {
"access_token": access_token,
"refresh_token": refresh_token,
"client_id": client_id or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
"inference_base_url": (inference_base_url or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
"token_type": token_type or "Bearer",
"scope": scope or DEFAULT_NOUS_SCOPE,
"obtained_at": obtained_at,
"expires_at": expires_at,
"agent_key": agent_key,
"agent_key_expires_at": agent_key_expires_at,
"tls": {
"insecure": bool(insecure),
"ca_bundle": ca_bundle,
},
}
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
current_invoke_jwt_status = _nous_invoke_jwt_status(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if force_refresh or current_invoke_jwt_status is not None:
refresh_token_value = state.get("refresh_token")
if not isinstance(refresh_token_value, str) or not refresh_token_value:
if current_invoke_jwt_status is not None:
raise _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({current_invoke_jwt_status}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
current_invoke_jwt_status, relogin=True,
)
raise _nous_err(
"No refresh token is available for Nous Portal.",
relogin=True,
)
refreshed = _refresh_access_token(
client=client,
portal_base_url=state["portal_base_url"],
client_id=state["client_id"],
refresh_token=refresh_token_value,
)
_apply_nous_refreshed_tokens(
state, refreshed, refresh_token_value,
inference_base_url=_healed_nous_inference_url(refreshed),
)
if on_state_update is not None:
on_state_update(dict(state), "post_refresh_access_token")
_assert_nous_inference_jwt_usable(state)
_select_nous_invoke_jwt(state)
return state
def refresh_nous_oauth_from_state(
state: Dict[str, Any],
*,
timeout_seconds: float = 15.0,
force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None,
) -> Dict[str, Any]:
"""Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure."""
tls = state.get("tls") or {}
return refresh_nous_oauth_pure(
state.get("access_token", ""),
state.get("refresh_token", ""),
state.get("client_id", "hermes-cli"),
state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL),
state.get("inference_base_url", DEFAULT_NOUS_INFERENCE_URL),
token_type=state.get("token_type", "Bearer"),
scope=state.get("scope", DEFAULT_NOUS_SCOPE),
obtained_at=state.get("obtained_at"),
expires_at=state.get("expires_at"),
agent_key=state.get("agent_key"),
agent_key_expires_at=state.get("agent_key_expires_at"),
timeout_seconds=timeout_seconds,
insecure=tls.get("insecure"),
ca_bundle=tls.get("ca_bundle"),
force_refresh=force_refresh,
on_state_update=on_state_update,
)
def persist_nous_credentials(
creds: Dict[str, Any],
*,
label: Optional[str] = None,
):
"""Persist Nous OAuth credentials as the singleton provider state
Nous credentials are read from two places: ``providers.nous`` (401 recovery, pool seeding) and
``credential_pool.nous`` (runtime ``pool.select()``). Writing only a pool entry left the
singleton empty and made expiry recovery fail silently, so this writes the singleton and then
``load_pool("nous")`` upserts the canonical ``device_code`` entry in place (never duplicates).
``label`` is embedded in the singleton so re-seeding keeps the user's display name.
"""
from agent.credential_pool import load_pool
state = dict(creds)
if label and str(label).strip():
state["label"] = str(label).strip()
_save_active_provider_state("nous", state)
# Mirror to the shared store so a new profile can one-tap import
# these credentials via `hermes auth add nous --type oauth`. Best-
# effort: any I/O failure is logged and swallowed (the per-profile
# auth.json is still the source of truth).
_write_shared_nous_state(state)
pool = load_pool("nous")
return next(
(e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
None,
)
def _sync_nous_pool_from_auth_store() -> None:
"""Best-effort pool reseed after providers.nous changes; never fail login."""
try:
from agent.credential_pool import load_pool
load_pool("nous")
except Exception as exc:
logger.debug("Failed to sync Nous credential pool from auth store: %s", exc)
class _NousStatePersister:
"""Writes Nous provider state to its source store, skipping no-op writes.
Writes where only derived TTL countdowns changed are skipped; this keeps the mtime-keyed Nous
auth-status cache warm during read paths. Every real write is mirrored to the shared store so
sibling profiles don't hold stale refresh_tokens after rotation (best-effort — failures are
logged and swallowed inside ``_write_shared_nous_state``).
"""
def __init__(
self,
auth_store: Dict[str, Any],
state: Dict[str, Any],
state_source_path: Optional[Path],
sequence_id: str,
) -> None:
self._auth_store = auth_store
self._state = state
self._source_path = state_source_path
self._sequence_id = sequence_id
self._persisted_state = dict(state)
self.persisted_any = False
def persist(self, reason: str) -> None:
state = self._state
if (
_nous_effective_provider_state(state)
== _nous_effective_provider_state(self._persisted_state)
):
_oauth_trace(
"nous_state_persist_skipped",
sequence_id=self._sequence_id,
reason=reason,
)
return
try:
_save_provider_state_to_source(self._auth_store, "nous", state, self._source_path)
except Exception as exc:
_oauth_trace(
"nous_state_persist_failed",
sequence_id=self._sequence_id,
reason=reason,
error_type=type(exc).__name__,
)
raise
_oauth_trace(
"nous_state_persisted",
sequence_id=self._sequence_id,
reason=reason,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
access_token_fp=_token_fingerprint(state.get("access_token")),
)
self._persisted_state = dict(state)
self.persisted_any = True
_write_shared_nous_state(state)
def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]:
"""Resolve every routing value that shared OAuth state can replace.
Returns ``(portal_url, stored_inference_url, effective_inference_url, client_id)``. The
stored inference URL is re-validated network-provenance (persisted); the effective one layers
the runtime-only ``NOUS_INFERENCE_BASE_URL`` override on top and must never be persisted.
"""
portal_url = (
_optional_base_url(state.get("portal_base_url"))
or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
# A persisted/stale portal_base_url is where the refresh token gets
# POSTed on refresh — reject any host outside the allowlist so a
# poisoned value can't exfiltrate the bearer, healing to the default.
# Trusted operator env overrides bypass this network-value gate.
env_portal_override = _nous_portal_env_override()
if env_portal_override:
portal_url = env_portal_override.rstrip("/")
else:
parsed_portal_url = urlparse(portal_url)
portal_host = parsed_portal_url.hostname
loopback_http = (
parsed_portal_url.scheme == "http"
and portal_host in {"localhost", "127.0.0.1"}
)
trusted_scheme = parsed_portal_url.scheme == "https" or loopback_http
if (
not portal_host
or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS
or not trusted_scheme
):
logger.warning(
"auth: ignoring invalid portal_base_url %r "
"(host %r or scheme not allowed), using default",
portal_url,
portal_host,
)
portal_url = DEFAULT_NOUS_PORTAL_URL
stored_inference_url = (
_validate_nous_inference_url_from_network(
_optional_base_url(state.get("inference_base_url"))
)
or DEFAULT_NOUS_INFERENCE_URL
)
return (
portal_url,
stored_inference_url,
_nous_inference_env_override() or stored_inference_url,
str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID),
)
def resolve_nous_runtime_credentials(
*,
timeout_seconds: float = 15.0,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
force_refresh: bool = False,
stale_access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Resolve Nous inference credentials for runtime use.
Ensures access_token is a valid inference-scoped JWT, refreshing it when
needed. Concurrent processes coordinate through the auth store file lock.
``stale_access_token`` is the bearer that just failed upstream (401). When
set together with ``force_refresh``, the refresh POST is skipped if the
store — re-read under the lock — already holds a *different*, usable
token: another process won the rotation, so this caller adopts it instead
of rotating the shared grant again (otherwise N concurrent processes at the
same expiry issue N refreshes, each invalidating a sibling's fresh token).
"""
sequence_id = uuid.uuid4().hex[:12]
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)
def _already_rotated_by_peer(token: Any) -> bool:
return bool(
force_refresh
and stale_access_token
and isinstance(token, str)
and token
and token != stale_access_token
and _nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
) is None
)
persister = _NousStatePersister(auth_store, state, state_source_path, sequence_id)
_persist_state = persister.persist
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
_oauth_trace(
"nous_runtime_credentials_start",
sequence_id=sequence_id,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
if not isinstance(access_token, str) or not access_token:
with _nous_shared_store_lock(
timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)
):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
_persist_state("runtime_shared_merge_missing_access_token")
if not isinstance(access_token, str) or not access_token:
raise _nous_err(
"No access token found for Nous Portal login.",
relogin=True,
)
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
# Under the store lock: if the bearer that failed upstream is no
# longer the one on disk and the on-disk one is usable, a peer
# already rotated — adopt, never re-POST the shared grant.
if _already_rotated_by_peer(access_token):
_oauth_trace(
"refresh_skipped_peer_rotated",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
force_refresh = False
if force_refresh or invoke_jwt_status is not None:
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
_persist_state("post_shared_merge_access_unusable")
if _already_rotated_by_peer(access_token):
_oauth_trace(
"refresh_skipped_peer_rotated",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
force_refresh = False
if force_refresh or invoke_jwt_status is not None:
if not isinstance(refresh_token, str) or not refresh_token:
reason = invoke_jwt_status or "force_refresh"
raise _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({reason}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
reason, relogin=True,
)
refresh_reason = "force_refresh" if force_refresh else (invoke_jwt_status or "access_unusable")
_oauth_trace(
"refresh_start",
sequence_id=sequence_id,
reason=refresh_reason,
refresh_token_fp=_token_fingerprint(refresh_token),
)
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="runtime_access_refresh_failure",
persist=lambda: _persist_state("terminal_runtime_access_refresh_failure"),
)
previous_refresh_token = refresh_token
# The validated, network-provenance URL is what gets persisted to
# auth.json (with the rotated tokens, so a later JWT validation
# failure cannot leave the stores on stale metadata). The
# NOUS_INFERENCE_BASE_URL env override is layered on for the
# client/return value only — it is never persisted.
stored_inference_base_url = _healed_nous_inference_url(refreshed)
inference_base_url = (
_nous_inference_env_override() or stored_inference_base_url
)
_apply_nous_refreshed_tokens(
state, refreshed, refresh_token,
inference_base_url=stored_inference_base_url,
)
access_token = state["access_token"]
refresh_token = state["refresh_token"]
_oauth_trace(
"refresh_success",
sequence_id=sequence_id,
reason=refresh_reason,
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
new_refresh_token_fp=_token_fingerprint(refresh_token),
)
# Persist immediately so validation failures cannot drop rotated refresh tokens.
_persist_state("post_refresh_access_token")
_assert_nous_inference_jwt_usable(
state,
access_token=access_token,
)
_select_nous_invoke_jwt(
state,
access_token=access_token,
sequence_id=sequence_id,
)
# Persist routing and TLS metadata for non-interactive refresh.
# Persist the validated, network-provenance URL — NEVER the env
# override (which is a runtime-only overlay; persisting it would
# leak a dev/staging host into auth.json and survive unsetting it).
state["portal_base_url"] = portal_base_url
state["inference_base_url"] = stored_inference_base_url
state["client_id"] = client_id
state["tls"] = _tls_state_from_verify(verify)
_persist_state("resolve_nous_runtime_credentials_final")
if persister.persisted_any:
_sync_nous_pool_from_auth_store()
api_key = state.get("agent_key")
if not isinstance(api_key, str) or not api_key:
raise _nous_err("Failed to resolve a Nous inference API key", "server_error")
expires_at = state.get("agent_key_expires_at")
expires_epoch = _parse_iso_timestamp(expires_at)
expires_in = (
max(0, int(expires_epoch - time.time()))
if expires_epoch is not None
else _coerce_ttl_seconds(state.get("agent_key_expires_in"))
)
return {
"provider": "nous",
"base_url": inference_base_url,
"api_key": api_key,
"key_id": state.get("agent_key_id"),
"expires_at": expires_at,
"expires_in": expires_in,
"source": NOUS_AUTH_PATH_INVOKE_JWT,
# Preserve the public semantic source label while exposing the concrete
# store separately for diagnostics. Refresh persistence uses
# state_source_path internally and must not overload this field.
"auth_path": NOUS_AUTH_PATH_INVOKE_JWT,
"state_path": str(state_source_path or _auth_file_path()),
}
# =============================================================================
# Status helpers
# =============================================================================
def _empty_nous_auth_status() -> Dict[str, Any]:
return {
"logged_in": False,
"portal_base_url": None,
"inference_base_url": None,
"access_expires_at": None,
"agent_key_expires_at": None,
"has_refresh_token": False,
"inference_credential_present": False,
"credential_source": None,
}
def _snapshot_nous_pool_status() -> Dict[str, Any]:
"""Best-effort status from the credential pool.
This is a fallback only. The auth-store provider state is the runtime source of truth because it
is what ``resolve_nous_runtime_credentials()`` refreshes.
"""
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
if not pool or not pool.has_credentials():
return _empty_nous_auth_status()
entries = list(pool.entries())
if not entries:
return _empty_nous_auth_status()
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
priority = int(getattr(entry, "priority", 0) or 0)
return (agent_exp, access_exp, -priority)
entry = max(entries, key=_entry_sort_key)
runtime_key = getattr(entry, "runtime_api_key", None)
if not runtime_key:
return _empty_nous_auth_status()
access_token = getattr(entry, "access_token", None)
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
refresh_token = getattr(entry, "refresh_token", None)
is_portal_oauth = bool(access_token) and (
auth_type.startswith("oauth") or bool(refresh_token)
)
label = getattr(entry, "label", "unknown")
portal_status_url = None
if is_portal_oauth:
portal_status_url = (
getattr(entry, "portal_base_url", None)
or DEFAULT_NOUS_PORTAL_URL
)
return {
"logged_in": is_portal_oauth,
"portal_base_url": portal_status_url,
"inference_base_url": getattr(entry, "inference_base_url", None)
or getattr(entry, "runtime_base_url", None)
or getattr(entry, "base_url", None),
"access_token": access_token if is_portal_oauth else None,
"access_expires_at": getattr(entry, "expires_at", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"has_refresh_token": bool(refresh_token),
"inference_credential_present": True,
"credential_source": f"pool:{label}",
"source": f"pool:{label}",
}
except Exception:
return _empty_nous_auth_status()
# ── 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 FileNotFoundError:
return auth_file_key, None
except Exception:
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
def _nous_status_from_state(state: Dict[str, Any], *, logged_in: bool, source: str) -> Dict[str, Any]:
"""Auth-store-backed Nous status snapshot (shared by the live and refresh-free variants)."""
access_token = state.get("access_token")
return {
"logged_in": logged_in,
"portal_base_url": state.get("portal_base_url"),
"inference_base_url": state.get("inference_base_url"),
"access_expires_at": state.get("expires_at"),
"agent_key_expires_at": state.get("agent_key_expires_at"),
"has_refresh_token": bool(state.get("refresh_token")),
"access_token": access_token,
"inference_credential_present": bool(access_token or state.get("agent_key")),
"credential_source": "auth_store",
"source": source,
}
def _compute_nous_auth_status() -> Dict[str, Any]:
"""Uncached implementation of get_nous_auth_status(). See that function."""
state = get_provider_auth_state("nous")
if state:
base_status = _nous_status_from_state(
state, logged_in=bool(state.get("access_token")), source="auth_store",
)
try:
creds = resolve_nous_runtime_credentials()
refreshed_state = get_provider_auth_state("nous") or state
base_status.update(
{
"logged_in": True,
"portal_base_url": refreshed_state.get("portal_base_url") or base_status.get("portal_base_url"),
"inference_base_url": creds.get("base_url")
or refreshed_state.get("inference_base_url")
or base_status.get("inference_base_url"),
"access_expires_at": refreshed_state.get("expires_at") or base_status.get("access_expires_at"),
"agent_key_expires_at": creds.get("expires_at")
or refreshed_state.get("agent_key_expires_at")
or base_status.get("agent_key_expires_at"),
"has_refresh_token": bool(refreshed_state.get("refresh_token")),
"inference_credential_present": True,
"credential_source": "auth_store",
"source": f"runtime:{creds.get('source', 'portal')}",
"key_id": creds.get("key_id"),
}
)
return base_status
except AuthError as exc:
base_status.update({
"logged_in": False,
"error": str(exc),
"relogin_required": bool(getattr(exc, "relogin_required", False)),
"error_code": getattr(exc, "code", None),
})
return base_status
return _snapshot_nous_pool_status()
def get_nous_auth_status_local() -> Dict[str, Any]:
"""Refresh-free Nous auth snapshot for read-only display surfaces.
Unlike :func:`get_nous_auth_status`, this NEVER calls ``resolve_nous_runtime_credentials()`` and
therefore never performs an OAuth refresh POST or consumes a single-use refresh token. It
reports the persisted auth-store state, classifying the access token with a local invoke-JWT
decode only.
``logged_in`` here means "a persisted login exists that the runtime can use or refresh": a
currently-usable invoke JWT, or a refresh token that has not been terminally quarantined. It
does not prove the refresh token is still accepted server-side — only a live resolve can do
that.
"""
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return _snapshot_nous_pool_status()
access_token = state.get("access_token")
jwt_reason = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
last_err = state.get("last_auth_error")
terminal = bool(
isinstance(last_err, dict)
and last_err.get("relogin_required")
and not (access_token or state.get("refresh_token"))
)
logged_in = (jwt_reason is None) or (
bool(state.get("refresh_token")) and not terminal
)
status = _nous_status_from_state(state, logged_in=logged_in, source="auth_store_local")
if terminal and isinstance(last_err, dict):
status["relogin_required"] = True
status["error_code"] = last_err.get("code")
status["error"] = last_err.get("message") or "re-login required"
return status
# Enum values reported on the dashboard /api/status as ``nous_session_valid``.
# NAS's health sweep re-mints the bootstrap session ONLY on "terminal"; "valid"
# and "unknown" are no-ops. Keep this set small and stable — NAS parses it with
# a permissive schema, so new members are non-breaking but should stay rare.
NOUS_SESSION_VALID = "valid"
NOUS_SESSION_TERMINAL = "terminal"
NOUS_SESSION_UNKNOWN = "unknown"
def get_nous_session_validity() -> str:
"""Classify the Nous bootstrap session for the dashboard /api/status probe.
Determinable with NO working token — it reads local auth-store state only, which is exactly the
condition a dead hosted box is in. This function is called by the frequently-polled public
``/api/status`` endpoint, so it must never resolve credentials or perform an OAuth refresh.
ANTI-FLAP CONTRACT: only a *terminal* failure maps to "terminal". A normal mid-rotation blip, a
transient network error, or a merely-expiring token must NOT report "terminal" (that would
trigger a spurious NAS re-mint on a healthy box).
"""
# A persisted quarantine marker is the strongest, most stable terminal
# signal: the refresh path writes `last_auth_error.relogin_required=True`
# into the Nous provider state when it clears dead tokens (the exact path
# that produced the incident's "No access token found"). Read it directly
# so we report "terminal" even after the in-memory AuthError is long gone.
try:
state = get_provider_auth_state("nous")
except Exception:
return NOUS_SESSION_UNKNOWN
if not state:
return NOUS_SESSION_UNKNOWN
last_err = state.get("last_auth_error")
# Only terminal while there is no usable credential left. If a later
# successful login repopulated tokens, the stale marker must not
# keep reporting terminal.
if (
isinstance(last_err, dict)
and last_err.get("relogin_required")
and not (state.get("access_token") or state.get("refresh_token"))
):
return NOUS_SESSION_TERMINAL
if _nous_invoke_jwt_status(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
) is None:
return NOUS_SESSION_VALID
# Missing, malformed, expired, or merely expiring credentials are not proof
# of a terminal session. Runtime inference/keepalive paths own refreshes;
# the health endpoint remains side-effect free and reports indeterminate.
return NOUS_SESSION_UNKNOWN
def _pool_first_oauth_status(
provider_id: str,
*,
is_expiring: Callable[[str, int], bool],
auth_mode: str,
resolve: Callable[[], Dict[str, Any]],
on_pool_miss: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
) -> Dict[str, Any]:
"""Status snapshot for a store-backed OAuth provider (Codex, xAI).
Checks the credential pool first (where `hermes auth` / `hermes model` store device_code
tokens), optionally consults *on_pool_miss* for a pool-derived degraded status, then falls
back to the legacy provider state via *resolve*.
"""
try:
from agent.credential_pool import load_pool
pool = load_pool(provider_id)
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
api_key = (
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
)
if api_key and not is_expiring(api_key, 0):
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": auth_mode,
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
if on_pool_miss is not None:
degraded = on_pool_miss()
if degraded:
return degraded
except Exception:
pass
try:
creds = resolve()
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
}
except AuthError as exc:
return {
"logged_in": False,
"auth_store": str(_auth_file_path()),
"error": str(exc),
}
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."""
target = (provider_id or get_active_provider() or "").strip().lower()
if not target:
return {"logged_in": False}
# Bespoke status builders win over the auth_type-keyed fallbacks. Looked up
# at call time so tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply.
bespoke: Dict[str, Callable[[], Dict[str, Any]]] = {
"spotify": get_spotify_auth_status,
"nous": get_nous_auth_status,
"openai-codex": get_codex_auth_status,
"xai-oauth": get_xai_oauth_auth_status,
"qwen-oauth": get_qwen_auth_status,
"minimax-oauth": get_minimax_oauth_auth_status,
"azure-foundry": _get_azure_foundry_auth_status,
}
if target in bespoke:
return bespoke[target]()
# External-process providers (copilot-acp today; other ACP backends tomorrow)
# dispatch on auth_type, not a hardcoded slug, so every provider of this
# class gets a real status instead of the ``{"logged_in": False}`` fallthrough.
by_auth_type: Dict[str, Callable[[str], Dict[str, Any]]] = {
"external_process": get_external_process_provider_status,
"api_key": get_api_key_provider_status,
"aws_sdk": _get_aws_sdk_auth_status,
}
pconfig = PROVIDER_REGISTRY.get(target)
if pconfig and pconfig.auth_type in by_auth_type:
return by_auth_type[pconfig.auth_type](target)
return {"logged_in": False}
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 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)
if provider_id in {"kimi-coding", "kimi-coding-cn"}:
base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
elif provider_id == "zai":
base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url)
elif provider_id == "copilot":
# Resolve the Copilot API base URL 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 and is guarded non-empty below so chat inference never
# resolves an empty base URL (#50252).
base_url = env_url.rstrip("/") if env_url else pconfig.inference_base_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)
elif env_url:
base_url = env_url.rstrip("/")
else:
base_url = pconfig.inference_base_url
if provider_id == "lmstudio":
base_url = _normalize_lmstudio_runtime_base_url(base_url)
if provider_id == "actual":
base_url = normalize_actual_base_url(base_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.
"""
# 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()
if provider in {"nous", "openai-codex", "xai-oauth"}:
return provider
return 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 _confirm_selection_guards(
model_id: str,
*,
provider: str = "",
base_url: str = "",
api_key: str = "",
include_kinds: Optional[List[str]] = None,
) -> bool:
"""Prompt before saving a model that trips any selection guard.
Runs the unified guard registry (cost, data-policy, future guards) and shows one [y/N] confirm
listing every warning that fired. Returns True to proceed, False to cancel.
"""
try:
from hermes_cli.model_selection_guards import (
combined_message,
selection_warnings,
)
warnings = selection_warnings(
model_id,
provider=provider,
base_url=base_url,
api_key=api_key,
include_kinds=include_kinds,
)
except Exception:
warnings = []
if not warnings:
return True
print()
print("=" * 72)
print(combined_message(warnings))
print("=" * 72)
try:
response = input("Switch anyway? [y/N]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return False
return response in {"y", "yes"}
class _ModelPickerRows:
"""Column-aligned model rows (name + $/Mtok prices + Nous sale chrome) for the model picker.
Sale chrome (★ / -N% / was) is drawn as curses/ANSI segments (yellow % / dim "was"), not baked
into one plain string — curses addnstr would otherwise render escape bytes literally.
"""
def __init__(
self,
all_models: List[str],
pricing: Optional[Dict[str, Dict[str, str]]],
*,
current_model: str,
sale_chrome: bool,
) -> None:
from hermes_cli.models import _format_price_per_mtok, compute_sale_discount
self.current_model = current_model
self.has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
# Leave room for a leading "★ " on sale rows (Nous only).
name_pad = 3 if sale_chrome else 2
self.name_col = (
max((len(m) for m in all_models), default=0) + name_pad
if self.has_pricing
else 0
)
# (inp, out, cache, pct|None, was_inp, was_out)
self._price_cache: dict[str, tuple[str, str, str, int | None, str, str]] = {}
self.price_col = 3 # minimum width
self.cache_col = 0 # only set if any model has cache pricing
self.has_cache = False
self.any_on_sale = False
if not self.has_pricing:
return
for mid in all_models:
p = pricing.get(mid) # type: ignore[union-attr]
pct: int | None = None
was_inp = was_out = ""
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
cache_read = p.get("input_cache_read", "")
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
self.has_cache = True
if sale_chrome:
sale = compute_sale_discount(
p.get("prompt", ""),
p.get("completion", ""),
p.get("original"),
)
if sale is not None:
self.any_on_sale = True
pct, was_prompt_raw, was_out_raw = sale
# Natively-free models (no gateway original) carry
# empty was_* raws — leave them empty so the row
# shows bare "-100%" with no "was ?/?" suffix.
if was_prompt_raw == "" and was_out_raw == "":
was_inp = was_out = ""
else:
was_inp = (
_format_price_per_mtok(was_prompt_raw)
if was_prompt_raw != ""
else "?"
)
was_out = (
_format_price_per_mtok(was_out_raw)
if was_out_raw != ""
else "?"
)
else:
inp, out, cache = "", "", ""
self._price_cache[mid] = (inp, out, cache, pct, was_inp, was_out)
self.price_col = max(self.price_col, len(inp), len(out))
self.cache_col = max(self.cache_col, len(cache))
if self.has_cache:
self.cache_col = max(self.cache_col, 5) # minimum: "Cache" header
def segments(self, mid: str) -> list[tuple[str, str | None]]:
"""Build a rich radiolist row: yellow ★/% , dim was, plain prices."""
if not self.has_pricing:
segs: list[tuple[str, str | None]] = [(mid, None)]
if mid == self.current_model:
segs.append((" ← currently in use", None))
return segs
inp, out, cache, pct, was_inp, was_out = self._price_cache.get(
mid, ("", "", "", None, "", "")
)
on_sale = pct is not None
# Reserve 2 columns for "★ " so sale and non-sale names share alignment.
star_w = 2
if on_sale:
name_segs: list[tuple[str, str | None]] = [
("★ ", "yellow"),
(f"{mid:<{self.name_col - star_w}}", None),
]
else:
name_segs = [(f"{mid:<{self.name_col}}", None)]
price_part = f" {inp:>{self.price_col}} {out:>{self.price_col}}"
if self.has_cache:
price_part += f" {cache:>{self.cache_col}}"
segs = [*name_segs, (price_part, None)]
if on_sale:
segs.append((f" -{pct}%", "yellow"))
if was_inp or was_out:
segs.append((f" was {was_inp}/{was_out}", "dim"))
if mid == self.current_model:
segs.append((" ← currently in use", None))
return segs
def label(self, mid: str) -> str:
return "".join(text for text, _style in self.segments(mid))
def menu_title(self) -> str:
"""``Select default model:`` plus an aligned pricing header hint when priced."""
title = "Select default model:"
if self.has_pricing:
# Align the header with the model column.
# Each choice is " {label}" (2 spaces) and we prepend
# a 3-char cursor region ("-> " or " "), so content starts at col 5.
pad = " " * 5
header = f"\n{pad}{'':>{self.name_col}} {'In':>{self.price_col}} {'Out':>{self.price_col}}"
if self.has_cache:
header += f" {'Cache':>{self.cache_col}}"
# Legend lives on the column-header line so it reads as a key
# (★ = on sale), not a fake menu row.
title += header + " $/Mtok"
if self.any_on_sale:
title += " ★ = on sale"
return title
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
pricing: Optional[Dict[str, Dict[str, str]]] = None,
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
unavailable_message: str = "",
confirm_provider: str = "",
confirm_base_url: str = "",
confirm_api_key: str = "",
) -> Optional[str]:
"""Interactive model picker; current_model listed first. Returns the chosen model ID or None.
With *pricing* (``{model_id: {prompt, completion}}``) a compact price column is shown; models in
*unavailable_models* render grayed out and unselectable with an upgrade link to *portal_url*.
"""
from hermes_cli.cli_output import line_input
_unavailable = unavailable_models or []
# Sale chrome (★ / -N% / was) is Nous Portal-only — never for OpenRouter
# or other providers even if pricing.original is somehow present.
sale_chrome = (confirm_provider or "").strip().lower() == "nous"
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
return None
# Unified guard registry (hermes_cli.model_selection_guards): the cost
# guard only runs when a provider is known (pricing lookups need one);
# id-keyed guards like the data-policy guard always run — they must
# fire even via a custom endpoint or gateway.
_kinds = None if confirm_provider else ["data_policy"]
if not _confirm_selection_guards(
mid,
provider=confirm_provider,
base_url=confirm_base_url,
api_key=confirm_api_key,
include_kinds=_kinds,
):
return None
return mid
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
ordered.append(current_model)
for mid in model_ids:
if mid not in ordered:
ordered.append(mid)
# All models for column-width computation (selectable + unavailable)
rows = _ModelPickerRows(
list(ordered) + list(_unavailable), pricing,
current_model=current_model, sale_chrome=sale_chrome,
)
_DIM = "\033[2m"
_RESET = "\033[0m"
# Default cursor on the current model (index 0 if it was reordered to top)
default_idx = 0
menu_title = rows.menu_title()
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
# Try arrow-key menu first, fall back to number input.
try:
from hermes_cli.curses_ui import curses_radiolist
choices = [rows.segments(mid) for mid in ordered]
choices.append("Enter custom model name")
choices.append("Skip (keep current)")
unavailable_footer = unavailable_message.strip()
if not unavailable_footer and _unavailable:
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
# The pricing column header (and any unavailable-models block) is shown
# as a multi-line description above the list so it survives the curses
# screen clear. menu_title already embeds the aligned price header.
desc_lines: list[str] = []
if rows.has_pricing:
# menu_title is "Select default model:\n<pad><header> $/Mtok\n…"
# Keep only the header/legend portion for the description.
header_part = menu_title.split("\n", 1)
if len(header_part) > 1:
desc_lines.extend(header_part[1].splitlines())
if _unavailable:
for mid in _unavailable:
desc_lines.append(f" {rows.label(mid)}")
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
# Search haystacks keep pricing labels visible while adding aliases
# for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi").
from hermes_cli.model_search import model_search_text
model_search_labels = []
for mid in ordered:
label = rows.label(mid)
haystack = model_search_text(mid)
# model_search_text always starts with the wire id; only append when
# aliases add tokens beyond the bare id already in the label.
model_search_labels.append(
label if haystack == mid else f"{label} {haystack}"
)
model_search_labels.append("Enter custom model name")
model_search_labels.append("Skip (keep current)")
idx = curses_radiolist(
"Select default model:",
choices,
selected=default_idx,
cancel_returns=-1,
description=description,
searchable=True,
search_labels=model_search_labels,
)
if idx < 0:
return None
print()
if idx < len(ordered):
return _confirmed_selection(ordered[idx])
elif idx == len(ordered):
try:
custom = line_input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return _confirmed_selection(custom) if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
# Fallback: numbered list (ANSI colors for sale chrome)
from hermes_cli.curses_ui import format_radio_item_ansi
from hermes_cli.colors import Colors, color
for line in menu_title.splitlines():
if "★" in line:
print(line.replace("★", color("★", Colors.YELLOW), 1))
else:
print(line)
num_width = len(str(len(ordered) + 2))
for i, mid in enumerate(ordered, 1):
print(f" {i:>{num_width}}. {format_radio_item_ansi(rows.segments(mid))}")
n = len(ordered)
print(f" {n + 1:>{num_width}}. Enter custom model name")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
if _unavailable:
unavailable_footer = unavailable_message.strip() or (
f"Unavailable models (requires paid tier — upgrade at {_upgrade_url})"
)
print()
print(f" {_DIM}── {unavailable_footer} ──{_RESET}")
for mid in _unavailable:
print(f" {'':>{num_width}} {_DIM}{rows.label(mid)}{_RESET}")
print()
while True:
try:
choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip()
if not choice:
return None
idx = int(choice)
if 1 <= idx <= n:
return _confirmed_selection(ordered[idx - 1])
elif idx == n + 1:
custom = line_input("Enter model name: ").strip()
return _confirmed_selection(custom) if custom else None
elif idx == n + 2:
return None
print(f"Please enter 1-{n + 2}")
except ValueError:
print("Please enter a number")
except (KeyboardInterrupt, EOFError):
return None
def _save_model_choice(model_id: str) -> None:
"""Save the selected model to config.yaml (single source of truth).
The model is stored in config.yaml only — NOT in .env. This avoids conflicts in multi-agent
setups where env vars would stomp each other.
"""
from hermes_cli.config import save_config, load_config
config = load_config()
# Always use dict format so provider/base_url can be stored alongside
if isinstance(config.get("model"), dict):
config["model"]["default"] = model_id
else:
config["model"] = {"default": model_id}
save_config(config)
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)
def _prompt_yes_no(prompt: str, *, default: str) -> bool:
"""``input()`` a [Y/n]-style question; EOF/Ctrl-C count as *default*."""
try:
answer = input(prompt).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = default
return answer in {"", "y", "yes"} if default == "y" else answer in {"y", "yes"}
def _print_login_success(provider_id: str, config_path: Path, *, show_auth_state: bool = False) -> None:
print()
print("Login successful!")
if show_auth_state:
from hermes_constants import display_hermes_home as _dhh
print(f" Auth state: {_dhh()}/auth.json")
print(f" Config updated: {config_path} (model.provider={provider_id})")
def _offer_existing_oauth_credentials(
provider_id: str,
*,
resolve: Callable[[], Dict[str, Any]],
is_expiring: Callable[[str, int], bool],
display_name: str,
default_base_url: str,
expired_notice: Optional[str] = None,
) -> bool:
"""Offer to reuse still-valid stored OAuth credentials. Returns True when the user accepted.
*resolve* attempts a refresh, so a resolved token should be valid — but double-check the
expiry before telling the user "Login successful!".
"""
try:
existing = resolve()
api_key = existing.get("api_key", "")
if isinstance(api_key, str) and api_key and not is_expiring(api_key, 60):
print(f"Existing {display_name} credentials found in Hermes auth store.")
if _prompt_yes_no("Use existing credentials? [Y/n]: ", default="y"):
config_path = _update_config_for_provider(
provider_id, existing.get("base_url", default_base_url),
)
_print_login_success(provider_id, config_path)
return True
elif expired_notice:
print(expired_notice)
except AuthError:
pass
return False
def _login_openai_codex(
args,
pconfig: ProviderConfig,
*,
force_new_login: bool = False,
) -> None:
"""OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json."""
del args, pconfig # kept for parity with other provider login helpers
# Check for existing Hermes-owned credentials
if not force_new_login and _offer_existing_oauth_credentials(
"openai-codex",
resolve=resolve_codex_runtime_credentials,
is_expiring=_codex_access_token_is_expiring,
display_name="Codex",
default_base_url=DEFAULT_CODEX_BASE_URL,
expired_notice="Existing Codex credentials are expired. Starting fresh login...",
):
return
# Check for existing Codex CLI tokens we can import
if not force_new_login:
cli_tokens = _import_codex_cli_tokens()
if cli_tokens:
print("Found existing Codex CLI credentials at ~/.codex/auth.json")
print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.")
if _prompt_yes_no(
"Import these credentials? (a separate login is recommended) [y/N]: ", default="n",
):
_save_codex_tokens(cli_tokens)
config_path = _update_config_for_provider("openai-codex", _codex_base_url())
print()
print("Credentials imported. Note: if Codex CLI refreshes its token,")
print("Hermes will keep working independently with its own session.")
print(f" Config updated: {config_path} (model.provider=openai-codex)")
return
# Run a fresh device code flow — Hermes gets its own OAuth session
print()
print("Signing in to OpenAI Codex...")
print("(Hermes creates its own session — won't affect Codex CLI or VS Code)")
print()
creds = _codex_device_code_login()
# Save tokens to Hermes auth store
_save_codex_tokens(creds["tokens"], creds.get("last_refresh"))
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
_print_login_success("openai-codex", config_path, show_auth_state=True)
def _login_xai_oauth(
args,
pconfig: ProviderConfig,
*,
force_new_login: bool = False,
) -> None:
del pconfig
if not force_new_login and _offer_existing_oauth_credentials(
"xai-oauth",
resolve=resolve_xai_oauth_runtime_credentials,
is_expiring=_xai_access_token_is_expiring,
display_name="xAI OAuth",
default_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
):
return
print()
print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
print("(Hermes creates its own local OAuth session)")
print()
timeout_seconds = float(getattr(args, "timeout", None) or 20.0)
open_browser = not getattr(args, "no_browser", False)
if _is_remote_session():
open_browser = False
creds = _xai_oauth_device_code_login(
timeout_seconds=timeout_seconds,
open_browser=open_browser,
)
_save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
auth_mode="oauth_device_code",
)
# An explicit interactive re-login is a strong signal the user wants the
# xAI credential re-enabled. ``hermes auth remove xai-oauth`` leaves a
# ``device_code`` suppression marker that otherwise stops the singleton
# seed from re-creating the pool entry, so ``hermes auth list`` would show
# nothing even though the agent still works via the singleton fallback.
# Clear it here (same helper ``auth_add_command`` uses). This is kept OUT
# of ``_save_xai_oauth_tokens`` on purpose — that helper is shared with the
# refresh hot path, which must never mutate suppression state.
unsuppress_credential_source("xai-oauth", "device_code")
config_path = _update_config_for_provider("xai-oauth", creds.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL))
_print_login_success("xai-oauth", config_path, show_auth_state=True)
def _xai_oauth_request_device_code(
client: httpx.Client,
*,
scope: str = XAI_OAUTH_SCOPE,
) -> Dict[str, Any]:
response = client.post(
XAI_OAUTH_DEVICE_CODE_URL,
headers=_FORM_JSON_HEADERS,
data={
"client_id": XAI_OAUTH_CLIENT_ID,
"scope": scope,
},
)
if response.status_code != 200:
raise _xai_err(
f"xAI device-code request failed (HTTP {response.status_code})."
+ (f" Response: {response.text.strip()}" if response.text else ""),
"device_code_request_failed",
)
payload = response.json()
required = (
"device_code",
"user_code",
"verification_uri",
"verification_uri_complete",
"expires_in",
"interval",
)
missing = [key for key in required if key not in payload]
if missing:
raise _xai_err(
f"xAI device-code response missing fields: {', '.join(missing)}",
"device_code_invalid",
)
return payload
def _xai_oauth_poll_device_token(
client: httpx.Client,
*,
token_endpoint: str,
device_code: str,
expires_in: int,
poll_interval: int,
) -> Dict[str, Any]:
def _validate(payload: Dict[str, Any]) -> None:
for field_name, article in (("access_token", "an"), ("refresh_token", "a")):
if not payload.get(field_name):
raise _xai_err(
f"xAI device-code token response did not include {article} {field_name}.",
"xai_device_token_invalid",
)
def _error(response, error_payload) -> Exception:
description = (
error_payload.get("error_description")
or error_payload.get("error")
or response.text
)
return _xai_err(
f"xAI device-code token polling failed: {description}",
"xai_device_token_failed",
)
return _poll_device_token_generic(
lambda: client.post(
token_endpoint,
headers=_FORM_JSON_HEADERS,
data={
"grant_type": DEVICE_CODE_GRANT_TYPE,
"client_id": XAI_OAUTH_CLIENT_ID,
"device_code": device_code,
},
),
expires_in=int(expires_in),
poll_interval=max(1, int(poll_interval)),
validate_success=_validate,
on_non_json_error=lambda _r: _xai_err(
"xAI device-code token polling returned a non-JSON error response.",
"xai_device_token_failed",
),
on_error=_error,
on_timeout=lambda: _xai_err(
"Timed out waiting for xAI device authorization.",
"device_code_timeout",
),
)
def _xai_oauth_device_code_login(
*,
timeout_seconds: float = 20.0,
open_browser: bool = True,
) -> Dict[str, Any]:
discovery = _xai_oauth_discovery(timeout_seconds)
token_endpoint = discovery["token_endpoint"]
timeout = httpx.Timeout(max(20.0, timeout_seconds))
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
device_data = _xai_oauth_request_device_code(client)
verification_url = str(
device_data.get("verification_uri_complete")
or device_data["verification_uri"]
)
user_code = str(device_data["user_code"])
expires_in = int(device_data["expires_in"])
interval = int(device_data["interval"])
_print_device_code_instructions(
verification_url,
user_code,
open_browser=open_browser and not _is_remote_session() and _can_open_graphical_browser(),
swallow_open_errors=True,
)
print(f"Waiting for approval (polling every {max(1, interval)}s)...")
payload = _xai_oauth_poll_device_token(
client,
token_endpoint=token_endpoint,
device_code=str(device_data["device_code"]),
expires_in=expires_in,
poll_interval=interval,
)
access_token = str(payload.get("access_token", "") or "").strip()
refresh_token = str(payload.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
raise _xai_err(
"xAI device-code token response was missing required tokens.",
"xai_device_token_invalid",
)
base_url = _xai_oauth_inference_base_url()
return {
"tokens": _xai_tokens_from_payload(payload, access_token, refresh_token),
"discovery": discovery,
"redirect_uri": "",
"base_url": base_url,
"last_refresh": _utc_now_z(),
"source": "oauth-device-code",
}
def _codex_login_rate_limited_error(response: "httpx.Response", *, during: str = "") -> AuthError:
"""AuthError for a 429 from OpenAI's device-auth endpoints (a throttle, not a credential fault)."""
retry_after = _parse_retry_after_seconds(getattr(response, "headers", None))
wait_hint = (
f" Try again in about {retry_after}s."
if retry_after is not None
else " Wait a minute and run the login again."
)
return _codex_err(
f"OpenAI is rate-limiting Codex login requests (HTTP 429){during}. "
"This is a temporary throttle on OpenAI's side, not a credential "
f"problem.{wait_hint}",
CODEX_RATE_LIMITED_CODE,
)
def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]:
"""Step 1 of the Codex device flow: request a user code, retrying capped on HTTP 429."""
# OpenAI's auth endpoint rate-limits this request (HTTP 429) when login is
# attempted too often from the same IP/account — retry with capped backoff
# (honoring ``Retry-After``) before surfacing a clear, actionable message.
resp = None
max_attempts = 4
for attempt in range(1, max_attempts + 1):
try:
with _codex_http_client(timeout=httpx.Timeout(15.0)) as client:
resp = client.post(
f"{issuer}/api/accounts/deviceauth/usercode",
json={"client_id": client_id},
headers={"Content-Type": "application/json"},
)
except Exception as exc:
raise _codex_err(f"Failed to request device code: {exc}", "device_code_request_failed")
if resp.status_code != 429:
break
if attempt < max_attempts:
retry_after = _parse_retry_after_seconds(
getattr(resp, "headers", None)
)
# Exponential backoff (2s, 4s, 8s) capped, preferring the
# server-provided Retry-After when present.
delay = retry_after if retry_after is not None else 2 ** attempt
delay = max(1, min(int(delay), 60))
print(
"OpenAI is rate-limiting login requests "
f"(429); retrying in {delay}s..."
)
time.sleep(delay)
if resp is not None and resp.status_code == 429:
raise _codex_login_rate_limited_error(resp)
if resp is None or resp.status_code != 200:
status = resp.status_code if resp is not None else "unknown"
raise _codex_err(
f"Device code request returned status {status}.",
"device_code_request_error",
)
device_data = resp.json()
device_data["interval"] = max(3, int(device_data.get("interval", "5")))
if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""):
raise _codex_err("Device code response missing required fields.", "device_code_incomplete")
return device_data
def _codex_poll_authorization_code(
issuer: str, *, device_auth_id: str, user_code: str, poll_interval: int,
) -> Dict[str, Any]:
"""Step 3 of the Codex device flow: poll until sign-in completes (403/404 = still pending)."""
max_wait = 15 * 60 # 15 minutes
start = time.monotonic()
code_resp = None
try:
with _codex_http_client(timeout=httpx.Timeout(15.0)) as client:
while time.monotonic() - start < max_wait:
time.sleep(poll_interval)
poll_resp = client.post(
f"{issuer}/api/accounts/deviceauth/token",
json={"device_auth_id": device_auth_id, "user_code": user_code},
headers={"Content-Type": "application/json"},
)
if poll_resp.status_code == 200:
code_resp = poll_resp.json()
break
elif poll_resp.status_code in {403, 404}:
continue # User hasn't completed login yet
else:
raise _codex_err(
f"Device auth polling returned status {poll_resp.status_code}.",
"device_code_poll_error",
)
except KeyboardInterrupt:
print("\nLogin cancelled.")
raise SystemExit(130)
if code_resp is None:
raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout")
return code_resp
def _codex_exchange_authorization_code(
issuer: str, client_id: str, code_resp: Dict[str, Any],
) -> Dict[str, Any]:
"""Step 4 of the Codex device flow: swap the authorization code for tokens."""
authorization_code = code_resp.get("authorization_code", "")
code_verifier = code_resp.get("code_verifier", "")
redirect_uri = f"{issuer}/deviceauth/callback"
if not authorization_code or not code_verifier:
raise _codex_err(
"Device auth response missing authorization_code or code_verifier.",
"device_code_incomplete_exchange",
)
try:
with _codex_http_client(timeout=httpx.Timeout(15.0)) as client:
token_resp = client.post(
CODEX_OAUTH_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": authorization_code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": code_verifier,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
raise _codex_err(f"Token exchange failed: {exc}", "token_exchange_failed")
if token_resp.status_code == 429:
raise _codex_login_rate_limited_error(token_resp, during=" during token exchange")
if token_resp.status_code != 200:
raise _codex_err(
f"Token exchange returned status {token_resp.status_code}.",
"token_exchange_error",
)
tokens = token_resp.json()
if not tokens.get("access_token", ""):
raise _codex_err(
"Token exchange did not return an access_token.",
"token_exchange_no_access_token",
)
return tokens
def _codex_device_code_login() -> Dict[str, Any]:
"""Run the OpenAI device code login flow and return credentials dict."""
issuer = "https://auth.openai.com"
client_id = CODEX_OAUTH_CLIENT_ID
device_data = _codex_request_device_code(issuer, client_id)
user_code = device_data["user_code"]
device_auth_id = device_data["device_auth_id"]
poll_interval = device_data["interval"]
# Step 2: Show user the code
print("To continue, follow these steps:\n")
print(" 1. Open this URL in your browser:")
print(f" \033[94m{issuer}/codex/device\033[0m\n")
print(" 2. Enter this code:")
print(f" \033[94m{user_code}\033[0m\n")
print("Waiting for sign-in... (press Ctrl+C to cancel)")
code_resp = _codex_poll_authorization_code(
issuer, device_auth_id=device_auth_id, user_code=user_code, poll_interval=poll_interval,
)
tokens = _codex_exchange_authorization_code(issuer, client_id, code_resp)
# Return tokens for the caller to persist (no longer writes to ~/.codex/)
return {
"tokens": {
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
},
"base_url": _codex_base_url(),
"last_refresh": _utc_now_z(),
"auth_mode": "chatgpt",
"source": "device-code",
}
# ==================== MiniMax Portal OAuth ====================
_MINIMAX_OAUTH_ERROR_BODY_LIMIT = 16 * 1024
def _minimax_response_error_text(
response: httpx.Response,
*,
limit: int = _MINIMAX_OAUTH_ERROR_BODY_LIMIT,
) -> str:
"""Return a bounded error body from a streamed MiniMax OAuth response."""
limit = max(0, int(limit))
chunks: list[bytes] = []
total = 0
truncated = False
try:
if getattr(response, "is_stream_consumed", False):
text = response.text
return text[:limit] + ("...[truncated]" if len(text) > limit else "")
for chunk in response.iter_bytes():
if not chunk:
continue
remaining = limit + 1 - total
if remaining <= 0:
truncated = True
break
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
total += remaining
truncated = True
break
chunks.append(chunk)
total += len(chunk)
raw = b"".join(chunks)
if len(raw) > limit:
raw = raw[:limit]
truncated = True
encoding = response.encoding or "utf-8"
text = raw.decode(encoding, errors="replace")
return text + ("...[truncated]" if truncated else "")
finally:
response.close()
def _minimax_post_form(
client: httpx.Client,
url: str,
*,
data: Dict[str, Any],
headers: Dict[str, str],
) -> httpx.Response:
"""POST a MiniMax OAuth form without eagerly reading error bodies."""
request = client.build_request(
"POST",
url,
data=data,
headers=headers,
)
response = client.send(request, stream=True)
if response.status_code == 200:
response.read()
return response
def _minimax_pkce_pair() -> tuple:
"""Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
import secrets
verifier = secrets.token_urlsafe(64)[:96]
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).decode().rstrip("=")
state = secrets.token_urlsafe(16)
return verifier, challenge, state
def _minimax_request_user_code(
client: httpx.Client, *, portal_base_url: str, client_id: str,
code_challenge: str, state: str,
) -> Dict[str, Any]:
response = _minimax_post_form(
client,
f"{portal_base_url}/oauth/code",
data={
"response_type": "code",
"client_id": client_id,
"scope": MINIMAX_OAUTH_SCOPE,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": state,
},
headers={**_FORM_JSON_HEADERS, "x-request-id": str(uuid.uuid4())},
)
if response.status_code != 200:
body = _minimax_response_error_text(response)
raise _minimax_err(
f"MiniMax OAuth authorization failed: {body or response.reason_phrase}",
"authorization_failed",
)
payload = response.json()
for field in ("user_code", "verification_uri", "expired_in"):
if field not in payload:
raise _minimax_err(
f"MiniMax OAuth response missing field: {field}",
"authorization_incomplete",
)
if payload.get("state") != state:
raise _minimax_err("MiniMax OAuth state mismatch (possible CSRF).", "state_mismatch")
return payload
def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool:
"""True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds)."""
return int(expired_in) > (now_ms // 2)
def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
"""Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
raw = int(expired_in)
now_ms = int(now.timestamp() * 1000)
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
return raw / 1000.0
return now.timestamp() + max(1, raw)
def _minimax_expiry_fields(expired_in: Any) -> Dict[str, Any]:
"""``obtained_at`` / ``expires_at`` / ``expires_in`` derived from a MiniMax ``expired_in``."""
now = datetime.now(timezone.utc)
expires_at_unix = _minimax_resolve_token_expiry_unix(int(expired_in), now=now)
return {
"obtained_at": now.isoformat(),
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
"expires_in": max(0, int(expires_at_unix - now.timestamp())),
}
def _minimax_poll_token(
client: httpx.Client, *, portal_base_url: str, client_id: str,
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
) -> Dict[str, Any]:
# OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
deadline = _minimax_resolve_token_expiry_unix(expired_in, now=datetime.now(timezone.utc))
interval = max(2.0, (interval_ms or 2000) / 1000.0)
while time.time() < deadline:
response = _minimax_post_form(
client,
f"{portal_base_url}/oauth/token",
data={
"grant_type": MINIMAX_OAUTH_GRANT_TYPE,
"client_id": client_id,
"user_code": user_code,
"code_verifier": code_verifier,
},
headers=_FORM_JSON_HEADERS,
)
error_text = ""
if response.status_code != 200:
error_text = _minimax_response_error_text(response)
try:
payload = json.loads(error_text) if error_text else {}
except Exception:
payload = {}
msg = (payload.get("base_resp", {}) or {}).get("status_msg") or error_text
raise _minimax_err(f"MiniMax OAuth error: {msg or 'unknown'}", "token_exchange_failed")
try:
payload = response.json() if response.text else {}
except Exception:
payload = {}
status = payload.get("status")
if status == "error":
raise _minimax_err(
"MiniMax OAuth reported an error. Please try again later.",
"authorization_denied",
)
if status == "success":
if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
raise _minimax_err(
"MiniMax OAuth success payload missing required token fields.",
"token_incomplete",
)
return payload
# "pending" or any other status -> keep polling
time.sleep(interval)
raise _minimax_err("MiniMax OAuth timed out before authorization completed.", "timeout")
def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
"""Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
_save_active_provider_state("minimax-oauth", auth_state)
def _minimax_oauth_login(
*, region: str = "global", open_browser: bool = True,
timeout_seconds: float = 15.0,
) -> Dict[str, Any]:
"""Run MiniMax OAuth flow, persist tokens, return auth state dict."""
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
if region == "cn":
portal_base_url = pconfig.extra["cn_portal_base_url"]
inference_base_url = pconfig.extra["cn_inference_base_url"]
else:
portal_base_url = pconfig.portal_base_url
inference_base_url = pconfig.inference_base_url
verifier, challenge, state = _minimax_pkce_pair()
if _is_remote_session():
open_browser = False
print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
print(f"Portal: {portal_base_url}")
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
headers={"Accept": "application/json"},
follow_redirects=True) as client:
code_data = _minimax_request_user_code(
client, portal_base_url=portal_base_url,
client_id=pconfig.client_id,
code_challenge=challenge, state=state,
)
verification_url = str(code_data["verification_uri"])
user_code = str(code_data["user_code"])
_print_device_code_instructions(
verification_url,
user_code,
open_browser=open_browser and _can_open_graphical_browser(),
)
interval_raw = code_data.get("interval")
interval_ms = int(interval_raw) if interval_raw is not None else None
print("Waiting for approval...")
token_data = _minimax_poll_token(
client, portal_base_url=portal_base_url,
client_id=pconfig.client_id,
user_code=user_code, code_verifier=verifier,
expired_in=int(code_data["expired_in"]),
interval_ms=interval_ms,
)
auth_state = {
"provider": "minimax-oauth",
"region": region,
"portal_base_url": portal_base_url,
"inference_base_url": inference_base_url,
"client_id": pconfig.client_id,
"scope": MINIMAX_OAUTH_SCOPE,
"token_type": token_data.get("token_type", "Bearer"),
"access_token": token_data["access_token"],
"refresh_token": token_data["refresh_token"],
"resource_url": token_data.get("resource_url"),
**_minimax_expiry_fields(token_data["expired_in"]),
}
_minimax_save_auth_state(auth_state)
print("\u2713 MiniMax OAuth login successful.")
if msg := token_data.get("notification_message"):
print(f"Note from MiniMax: {msg}")
return auth_state
def _refresh_minimax_oauth_state(
state: Dict[str, Any], *, timeout_seconds: float = 15.0,
force: bool = False,
) -> Dict[str, Any]:
"""Refresh MiniMax OAuth access token if close to expiry (or forced)."""
if not state.get("refresh_token"):
raise _minimax_err(
"MiniMax OAuth state has no refresh_token; please re-login.",
"no_refresh_token", relogin=True,
)
try:
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
except Exception:
expires_at = 0.0
now = time.time()
if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
return state
portal_base_url = state["portal_base_url"]
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
follow_redirects=True) as client:
response = _minimax_post_form(
client,
f"{portal_base_url}/oauth/token",
data={
"grant_type": "refresh_token",
"client_id": state["client_id"],
"refresh_token": state["refresh_token"],
},
headers=_FORM_JSON_HEADERS,
)
# The non-200 branch reads a STREAMED body, so it must run while
# the client is still open — iter_bytes() after the client context
# closes raises (StreamClosed). The 200 path was already read by
# _minimax_post_form, so response.json() below is safe outside.
if response.status_code != 200:
body = _minimax_response_error_text(response)
body_lower = body.lower()
relogin = any(m in body_lower for m in
("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
raise _minimax_err(
f"MiniMax OAuth refresh failed: {body or response.reason_phrase}",
"refresh_failed", relogin=relogin,
)
payload = response.json()
if payload.get("status") != "success":
raise _minimax_err(
"MiniMax OAuth refresh did not return success.",
"refresh_failed", relogin=True,
)
new_state = dict(state)
new_state.update({
"access_token": payload["access_token"],
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
**_minimax_expiry_fields(payload["expired_in"]),
})
_minimax_save_auth_state(new_state)
return new_state
def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None:
"""Wipe dead tokens from auth.json after a terminal refresh failure.
Shared by the eager-resolve path and the lazy per-request token provider. Mirrors the
Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a network retry.
"""
if not (exc.relogin_required and state.get("refresh_token")):
return
_quarantine_flat_oauth_state(state, "minimax-oauth", exc)
try:
_minimax_save_auth_state(state)
except Exception as _save_exc:
logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc)
def _minimax_fresh_state() -> Dict[str, Any]:
"""Load the MiniMax OAuth state and refresh it if near expiry; quarantine on terminal failure."""
state = get_provider_auth_state("minimax-oauth")
if not state or not state.get("access_token"):
raise _minimax_err(
"Not logged into MiniMax OAuth. Run `hermes model` and select "
"MiniMax (OAuth).",
"not_logged_in", relogin=True,
)
try:
return _refresh_minimax_oauth_state(state)
except AuthError as exc:
_minimax_oauth_quarantine_on_terminal_refresh(state, exc)
raise
def build_minimax_oauth_token_provider() -> Callable[[], str]:
"""Return a zero-arg callable that yields a fresh MiniMax access token.
The Anthropic SDK caches ``api_key`` as a static string at construction time, so a session that
resolves credentials once at startup will keep sending the same bearer until MiniMax's server
returns 401 — typically ~15 minutes in, because MiniMax issues short-lived access tokens.
"""
def _provide() -> str:
state = _minimax_fresh_state()
token = state.get("access_token")
if not token:
raise _minimax_err(
"MiniMax OAuth state has no access_token after refresh.",
"no_access_token", relogin=True,
)
return token
return _provide
def resolve_minimax_oauth_runtime_credentials(
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
as_token_provider: bool = False,
) -> Dict[str, Any]:
"""Return {provider, api_key, base_url, source} for minimax-oauth.
The default (string ``api_key``) preserves the historical contract for diagnostic call sites
like ``hermes status`` that just want to know whether a valid token exists right now.
"""
state = _minimax_fresh_state()
if as_token_provider:
api_key: Any = build_minimax_oauth_token_provider()
else:
api_key = state["access_token"]
return {
"provider": "minimax-oauth",
"api_key": api_key,
"base_url": state["inference_base_url"].rstrip("/"),
"source": "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 _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
"""CLI entry for MiniMax OAuth login."""
region = getattr(args, "region", None) or "global"
open_browser = not getattr(args, "no_browser", False)
timeout = getattr(args, "timeout", None) or 15.0
try:
_minimax_oauth_login(
region=region, open_browser=open_browser, timeout_seconds=timeout,
)
except AuthError as exc:
print(format_auth_error(exc))
raise SystemExit(1)
def _nous_device_code_login(
*,
portal_base_url: Optional[str] = None,
inference_base_url: Optional[str] = None,
client_id: Optional[str] = None,
scope: Optional[str] = None,
open_browser: bool = True,
timeout_seconds: float = 15.0,
insecure: bool = False,
ca_bundle: Optional[str] = None,
on_verification: Optional[Callable[[str, str], None]] = None,
) -> Dict[str, Any]:
"""Run the Nous device-code flow and return full OAuth state without persisting."""
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
portal_base_url
or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or pconfig.portal_base_url
).rstrip("/")
requested_inference_url = (
inference_base_url
or os.getenv("NOUS_INFERENCE_BASE_URL")
or pconfig.inference_base_url
).rstrip("/")
client_id = client_id or pconfig.client_id
scope = scope or pconfig.scope
timeout = httpx.Timeout(timeout_seconds)
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
if _is_remote_session():
open_browser = False
print(f"Starting Hermes login via {pconfig.name}...")
print(f"Portal: {portal_base_url}")
if insecure:
print("TLS verification: disabled (--insecure)")
elif ca_bundle:
print(f"TLS verification: custom CA bundle ({ca_bundle})")
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
device_data = _request_device_code(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
scope=scope,
)
verification_url = str(device_data["verification_uri_complete"])
user_code = str(device_data["user_code"])
expires_in = int(device_data["expires_in"])
interval = int(device_data["interval"])
_print_device_code_instructions(
verification_url, user_code, open_browser=open_browser, failure_dash="—",
)
# Surface the verification URL/code to an out-of-band consumer (e.g. the
# TUI gateway, whose stdout is a JSON-RPC pipe — a plain print() there is
# dropped). Fired AFTER the print/browser block and BEFORE polling blocks,
# so the consumer can render the link while we wait. Best-effort.
if on_verification is not None:
try:
on_verification(verification_url, user_code)
except Exception:
pass
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...")
token_data = _poll_for_token(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
device_code=str(device_data["device_code"]),
expires_in=expires_in,
poll_interval=interval,
)
now = datetime.now(timezone.utc)
token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
expires_at = now.timestamp() + token_expires_in
resolved_inference_url = (
_optional_base_url(token_data.get("inference_base_url"))
or requested_inference_url
)
if resolved_inference_url != requested_inference_url:
print(f"Using portal-provided inference URL: {resolved_inference_url}")
auth_state = {
"portal_base_url": portal_base_url,
"inference_base_url": resolved_inference_url,
"client_id": client_id,
"scope": token_data.get("scope") or scope,
"token_type": token_data.get("token_type", "Bearer"),
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"obtained_at": now.isoformat(),
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
"expires_in": token_expires_in,
"tls": _tls_state_from_verify(verify),
**_NOUS_EMPTY_AGENT_KEY_FIELDS,
}
try:
return refresh_nous_oauth_from_state(
auth_state,
timeout_seconds=timeout_seconds,
force_refresh=False,
)
except AuthError as exc:
if exc.code == "subscription_required":
portal_url = auth_state.get(
"portal_base_url", DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
message = format_auth_error(exc)
print()
print(message)
print(f" Subscribe here: {portal_url}/billing")
print()
print("After subscribing, run `hermes model` again to finish setup.")
raise SystemExit(1)
raise
def _mirror_nous_state_best_effort(auth_state: Dict[str, Any]) -> None:
"""Mirror to the shared store + reseed the pool, swallowing all errors (same as _login_nous)."""
try:
_write_shared_nous_state(auth_state)
except Exception:
pass
try:
_sync_nous_pool_from_auth_store()
except Exception:
pass
def step_up_nous_billing_scope(
*,
open_browser: bool = True,
timeout_seconds: float = 15.0,
on_verification: Optional[Callable[[str, str], None]] = None,
) -> bool:
"""Re-run the device flow requesting ``billing:manage`` and persist the result.
Lazy step-up triggered by ``403 insufficient_scope``. The user must be ADMIN/OWNER and select
"Allow Remote Spending" in the portal, otherwise the server silently downscopes and this returns
False. Reuses the held credential's portal/inference URLs + client_id so the step-up targets the
same deployment, and persists like ``_login_nous`` but WITHOUT the model picker.
"""
prior = get_provider_auth_state("nous") or {}
pconfig = PROVIDER_REGISTRY["nous"]
# Build the step-up scope: existing scopes (if any) + billing:manage, deduped,
# order-stable. Fall back to the standard inference+tool+billing set.
_raw_scope = prior.get("scope")
prior_scope = _raw_scope if isinstance(_raw_scope, str) else ""
requested: list[str] = []
for tok in (prior_scope.split() or [NOUS_INFERENCE_INVOKE_SCOPE, "tool:invoke"]):
if tok and tok not in requested:
requested.append(tok)
if NOUS_BILLING_MANAGE_SCOPE not in requested:
requested.append(NOUS_BILLING_MANAGE_SCOPE)
scope = " ".join(requested)
auth_state = _nous_device_code_login(
portal_base_url=prior.get("portal_base_url") or None,
inference_base_url=prior.get("inference_base_url") or None,
client_id=prior.get("client_id") or pconfig.client_id,
scope=scope,
open_browser=open_browser,
timeout_seconds=timeout_seconds,
on_verification=on_verification,
)
_save_active_provider_state("nous", auth_state)
_mirror_nous_state_best_effort(auth_state)
granted = auth_state.get("scope")
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
def _pick_nous_model_after_login(auth_state: Dict[str, Any], inference_base_url: str) -> Optional[str]:
"""Fetch the curated Nous model list (tier/policy-filtered) and run the interactive picker.
Returns the selected model id, or None when the user skipped / nothing was selectable.
Raises on any fetch failure so the caller can print the "Login succeeded, but..." notice.
"""
runtime_key = auth_state.get("agent_key") or auth_state.get("access_token")
if not isinstance(runtime_key, str) or not runtime_key:
raise _nous_err("No runtime API key available to fetch models", "invalid_token")
from hermes_cli.models import (
get_curated_nous_model_ids, get_pricing_for_provider,
check_nous_free_tier, partition_nous_models_by_tier,
nous_policy_allowed_ids, restrict_to_nous_policy,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
model_ids = get_curated_nous_model_ids()
print()
unavailable_models: list = []
unavailable_message = ""
if model_ids:
pricing = get_pricing_for_provider("nous")
# Force fresh account data for model selection so recent credit
# purchases are reflected immediately.
free_tier = check_nous_free_tier(force_fresh=True)
_portal_for_recs = auth_state.get("portal_base_url", "")
# Narrow before the tier split, so a rescued id still has to
# pass the free/paid predicate.
_policy_allowed = nous_policy_allowed_ids()
_policy_narrowed = False
if free_tier:
try:
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
_account_info = get_nous_portal_account_info(force_fresh=True)
unavailable_message = (
format_nous_portal_entitlement_message(
_account_info,
capability="paid Nous models",
)
or ""
)
except Exception:
unavailable_message = ""
# The Portal's free/paidRecommendedModels endpoint is the source of
# truth for what's available *right now*. Augment the curated list with
# anything new the Portal flags so users on older Hermes builds still
# see newly-launched models without a CLI release.
union = (
union_with_portal_free_recommendations
if free_tier
else union_with_portal_paid_recommendations
)
model_ids, pricing = union(model_ids, pricing, _portal_for_recs)
_before_policy = model_ids
model_ids = restrict_to_nous_policy(
model_ids, _policy_allowed, rescue_empty=True,
)
_policy_narrowed = model_ids != _before_policy
if free_tier:
model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True,
)
_portal = auth_state.get("portal_base_url", "")
if model_ids:
from hermes_cli.nous_account import nous_policy_notice
_policy_notice = nous_policy_notice(removed=_policy_narrowed)
if _policy_notice:
print(_policy_notice)
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
return _prompt_model_selection(
model_ids, pricing=pricing,
unavailable_models=unavailable_models,
portal_url=_portal,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=inference_base_url,
confirm_api_key=runtime_key,
)
elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.")
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
else:
print("No curated models available for Nous Portal.")
return None
def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any]]:
"""Codex-style auto-import: offer to rehydrate a Nous credential from another profile.
Checks the shared store before launching a fresh device-code flow. Returns the refreshed
auth state when the user accepted and the import succeeded, else None.
"""
shared = _read_shared_nous_state()
if not shared:
return None
try:
shared_path = _nous_shared_store_path()
except RuntimeError:
shared_path = None
print()
if shared_path:
print(f"Found existing Nous OAuth credentials at {shared_path}")
else:
print("Found existing shared Nous OAuth credentials")
if not _prompt_yes_no("Import these credentials? [Y/n]: ", default="y"):
return None
print("Rehydrating Nous session from shared credentials...")
auth_state = _try_import_shared_nous_state(timeout_seconds=timeout_seconds)
if auth_state is None:
print("Could not refresh shared credentials — falling back to device-code login.")
return auth_state
def _login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow."""
timeout_seconds = getattr(args, "timeout", None) or 15.0
insecure = bool(getattr(args, "insecure", False))
ca_bundle = (
getattr(args, "ca_bundle", None)
or os.getenv("HERMES_CA_BUNDLE")
or os.getenv("SSL_CERT_FILE")
)
try:
auth_state = _offer_shared_nous_import(timeout_seconds)
if auth_state is None:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None),
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=timeout_seconds,
insecure=insecure,
ca_bundle=ca_bundle,
)
inference_base_url = auth_state["inference_base_url"]
# Snapshot the prior active_provider BEFORE _save_provider_state
# overwrites it to "nous". If the user picks "Skip (keep current)"
# during model selection below, we restore this so the user's previous
# provider (e.g. openrouter) is preserved.
with _auth_store_lock():
_prior_store = _load_auth_store()
prior_active_provider = _prior_store.get("active_provider")
saved_to = _save_active_provider_state("nous", auth_state)
# Mirror to the shared store so other profiles can one-tap import
# these credentials. Best-effort: any I/O failure is logged and
# swallowed inside the helper.
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print()
print("Login successful!")
print(f" Auth state: {saved_to}")
# Resolve model BEFORE writing provider to config.yaml so we never
# leave the config in a half-updated state (provider=nous but model
# still set to the previous provider's model, e.g. opus from
# OpenRouter). The auth.json active_provider was already set above.
selected_model = None
try:
selected_model = _pick_nous_model_after_login(auth_state, inference_base_url)
except Exception as exc:
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
print()
print(f"Login succeeded, but could not fetch available models. Reason: {message}")
# Write provider + model atomically so config is never mismatched.
# If no model was selected (user picked "Skip (keep current)",
# model list fetch failed, or no curated models were available),
# preserve the user's previous provider — don't silently switch
# them to Nous with a mismatched model. The Nous OAuth tokens
# stay saved for future use.
if not selected_model:
# Restore the prior active_provider that _save_provider_state
# overwrote to "nous". config.yaml model.provider is left
# untouched, so the user's previous provider is fully preserved.
with _auth_store_lock():
auth_store = _load_auth_store()
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
_save_auth_store(auth_store)
print()
print("No provider change. Nous credentials saved for future use.")
print(" Run `hermes model` again to switch to Nous Portal.")
return
config_path = _update_config_for_provider(
"nous", inference_base_url, default_model=selected_model,
)
if selected_model:
_save_model_choice(selected_model)
print(f"Default model set to: {selected_model}")
print(f" Config updated: {config_path} (model.provider=nous)")
except KeyboardInterrupt:
print("\nLogin cancelled.")
raise SystemExit(130)
except Exception as exc:
print(f"Login failed: {exc}")
raise SystemExit(1)
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}.")