diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 3f6700b7c7..a4204d9ddb 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1,19 +1,9 @@ -""" -Multi-provider authentication system for Hermes Agent. +"""Multi-provider authentication system for Hermes Agent. -Supports OAuth device code flows (Nous Portal, future: OpenAI Codex) and -traditional API key providers (OpenRouter, custom endpoints). Auth state -is persisted in ~/.hermes/auth.json with cross-process file locking. - -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 - -Nous authentication paths: -- Invoke JWT (preferred): use a scoped access_token directly for inference. +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 @@ -121,6 +111,11 @@ 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" @@ -205,9 +200,8 @@ def is_actual_local_base_url(base_url: str) -> bool: def normalize_actual_base_url(base_url: str) -> str: """Return Actual's OpenAI-compatible base URL. - Actual hosted inference is exposed at api.actual.inc, while the Actual - client's offline local server binds a loopback host. Both use a /v1 API - surface for Hermes' Responses transport. + 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: @@ -246,6 +240,26 @@ class ProviderConfig: 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", @@ -262,13 +276,9 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL, ), - "openai-api": ProviderConfig( - id="openai-api", - name="OpenAI API", - auth_type="api_key", - inference_base_url="https://api.openai.com/v1", - api_key_env_vars=("OPENAI_API_KEY",), - base_url_env_var="OPENAI_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", @@ -282,21 +292,13 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL, ), - "lmstudio": ProviderConfig( - id="lmstudio", - name="LM Studio", - auth_type="api_key", - inference_base_url="http://127.0.0.1:1234/v1", - api_key_env_vars=("LM_API_KEY",), - base_url_env_var="LM_BASE_URL", + "lmstudio": _api_key_provider( + "lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", + ("LM_API_KEY",), "LM_BASE_URL", ), - "copilot": ProviderConfig( - id="copilot", - name="GitHub Copilot", - auth_type="api_key", - inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL, - api_key_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), - base_url_env_var="COPILOT_API_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", @@ -305,79 +307,44 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL", ), - "gemini": ProviderConfig( - id="gemini", - name="Google AI Studio", - auth_type="api_key", - inference_base_url="https://generativelanguage.googleapis.com/v1beta", - api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"), - base_url_env_var="GEMINI_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": ProviderConfig( - id="zai", - name="Z.AI / GLM", - auth_type="api_key", - inference_base_url="https://api.z.ai/api/paas/v4", - api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), - base_url_env_var="GLM_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", ), - "kimi-coding": ProviderConfig( - id="kimi-coding", - name="Kimi / Moonshot", - auth_type="api_key", - # 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. - inference_base_url="https://api.moonshot.ai/v1", - api_key_env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"), - base_url_env_var="KIMI_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": ProviderConfig( - id="kimi-coding-cn", - name="Kimi / Moonshot (China)", - auth_type="api_key", - inference_base_url="https://api.moonshot.cn/v1", - api_key_env_vars=("KIMI_CN_API_KEY",), + "kimi-coding-cn": _api_key_provider( + "kimi-coding-cn", "Kimi / Moonshot (China)", "https://api.moonshot.cn/v1", + ("KIMI_CN_API_KEY",), ), - "stepfun": ProviderConfig( - id="stepfun", - name="StepFun Step Plan", - auth_type="api_key", - inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL, - api_key_env_vars=("STEPFUN_API_KEY",), - base_url_env_var="STEPFUN_BASE_URL", + "stepfun": _api_key_provider( + "stepfun", "StepFun Step Plan", STEPFUN_STEP_PLAN_INTL_BASE_URL, + ("STEPFUN_API_KEY",), "STEPFUN_BASE_URL", ), - "arcee": ProviderConfig( - id="arcee", - name="Arcee AI", - auth_type="api_key", - inference_base_url="https://api.arcee.ai/api/v1", - api_key_env_vars=("ARCEEAI_API_KEY",), - base_url_env_var="ARCEE_BASE_URL", + "arcee": _api_key_provider( + "arcee", "Arcee AI", "https://api.arcee.ai/api/v1", + ("ARCEEAI_API_KEY",), "ARCEE_BASE_URL", ), - "gmi": ProviderConfig( - id="gmi", - name="GMI Cloud", - auth_type="api_key", - inference_base_url="https://api.gmi-serving.com/v1", - api_key_env_vars=("GMI_API_KEY",), - base_url_env_var="GMI_BASE_URL", + "gmi": _api_key_provider( + "gmi", "GMI Cloud", "https://api.gmi-serving.com/v1", + ("GMI_API_KEY",), "GMI_BASE_URL", ), - "actual": ProviderConfig( - id="actual", - name="Actual Computer", - auth_type="api_key", - inference_base_url=DEFAULT_ACTUAL_BASE_URL, - api_key_env_vars=("ACTUAL_API_KEY",), - base_url_env_var="ACTUAL_BASE_URL", + "actual": _api_key_provider( + "actual", "Actual Computer", DEFAULT_ACTUAL_BASE_URL, + ("ACTUAL_API_KEY",), "ACTUAL_BASE_URL", ), - "minimax": ProviderConfig( - id="minimax", - name="MiniMax", - auth_type="api_key", - inference_base_url="https://api.minimax.io/anthropic", - api_key_env_vars=("MINIMAX_API_KEY",), - base_url_env_var="MINIMAX_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", @@ -390,184 +357,96 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE, "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}, ), - "anthropic": ProviderConfig( - id="anthropic", - name="Anthropic", - auth_type="api_key", - inference_base_url="https://api.anthropic.com", - # 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. - api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), - base_url_env_var="ANTHROPIC_BASE_URL", + # 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": ProviderConfig( - id="alibaba", - name="Qwen Cloud", - auth_type="api_key", - inference_base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - api_key_env_vars=("DASHSCOPE_API_KEY",), - base_url_env_var="DASHSCOPE_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": ProviderConfig( - id="alibaba-coding-plan", - name="Alibaba Cloud (Coding Plan)", - auth_type="api_key", - inference_base_url="https://coding-intl.dashscope.aliyuncs.com/v1", - api_key_env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"), - base_url_env_var="ALIBABA_CODING_PLAN_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": ProviderConfig( - id="minimax-cn", - name="MiniMax (China)", - auth_type="api_key", - inference_base_url="https://api.minimaxi.com/anthropic", - api_key_env_vars=("MINIMAX_CN_API_KEY",), - base_url_env_var="MINIMAX_CN_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": ProviderConfig( - id="deepseek", - name="DeepSeek", - auth_type="api_key", - inference_base_url="https://api.deepseek.com/v1", - api_key_env_vars=("DEEPSEEK_API_KEY",), - base_url_env_var="DEEPSEEK_BASE_URL", + "deepseek": _api_key_provider( + "deepseek", "DeepSeek", "https://api.deepseek.com/v1", + ("DEEPSEEK_API_KEY",), "DEEPSEEK_BASE_URL", ), - "xai": ProviderConfig( - id="xai", - name="xAI", - auth_type="api_key", - inference_base_url="https://api.x.ai/v1", - api_key_env_vars=("XAI_API_KEY",), - base_url_env_var="XAI_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", ), - "nvidia": ProviderConfig( - id="nvidia", - name="NVIDIA NIM", - auth_type="api_key", - inference_base_url="https://integrate.api.nvidia.com/v1", - api_key_env_vars=("NVIDIA_API_KEY",), - base_url_env_var="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", ), - "ai-gateway": ProviderConfig( - id="ai-gateway", - name="Vercel AI Gateway", - auth_type="api_key", - inference_base_url="https://ai-gateway.vercel.sh/v1", - api_key_env_vars=("AI_GATEWAY_API_KEY",), - base_url_env_var="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-zen": ProviderConfig( - id="opencode-zen", - name="OpenCode Zen", - auth_type="api_key", - inference_base_url="https://opencode.ai/zen/v1", - api_key_env_vars=("OPENCODE_ZEN_API_KEY",), - base_url_env_var="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", ), - "opencode-go": ProviderConfig( - id="opencode-go", - name="OpenCode Go", - auth_type="api_key", - # 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. - inference_base_url="https://opencode.ai/zen/go/v1", - api_key_env_vars=("OPENCODE_GO_API_KEY",), - base_url_env_var="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", ), - "opencode-free": ProviderConfig( - id="opencode-free", - name="OpenCode Free", - auth_type="api_key", - inference_base_url="https://opencode.ai/zen/v1", - # 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`. - api_key_env_vars=(), + "huggingface": _api_key_provider( + "huggingface", "Hugging Face", "https://router.huggingface.co/v1", + ("HF_TOKEN",), "HF_BASE_URL", ), - "kilocode": ProviderConfig( - id="kilocode", - name="Kilo Code", - auth_type="api_key", - inference_base_url="https://api.kilo.ai/api/gateway", - api_key_env_vars=("KILOCODE_API_KEY",), - base_url_env_var="KILOCODE_BASE_URL", + "xiaomi": _api_key_provider( + "xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", + ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL", ), - "huggingface": ProviderConfig( - id="huggingface", - name="Hugging Face", - auth_type="api_key", - inference_base_url="https://router.huggingface.co/v1", - api_key_env_vars=("HF_TOKEN",), - base_url_env_var="HF_BASE_URL", + "tencent-tokenhub": _api_key_provider( + "tencent-tokenhub", "Tencent TokenHub", "https://tokenhub.tencentmaas.com/v1", + ("TOKENHUB_API_KEY",), "TOKENHUB_BASE_URL", ), - "xiaomi": ProviderConfig( - id="xiaomi", - name="Xiaomi MiMo", - auth_type="api_key", - inference_base_url="https://api.xiaomimimo.com/v1", - api_key_env_vars=("XIAOMI_API_KEY",), - base_url_env_var="XIAOMI_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", ), - "tencent-tokenhub": ProviderConfig( - id="tencent-tokenhub", - name="Tencent TokenHub", - auth_type="api_key", - inference_base_url="https://tokenhub.tencentmaas.com/v1", - api_key_env_vars=("TOKENHUB_API_KEY",), - base_url_env_var="TOKENHUB_BASE_URL", + "ollama-cloud": _api_key_provider( + "ollama-cloud", "Ollama Cloud", DEFAULT_OLLAMA_CLOUD_BASE_URL, + ("OLLAMA_API_KEY",), "OLLAMA_BASE_URL", ), - "tencent-tokenplan": ProviderConfig( - id="tencent-tokenplan", - name="Tencent TokenPlan", - auth_type="api_key", - inference_base_url="https://api.lkeap.cloud.tencent.com/plan/anthropic", - api_key_env_vars=("TOKENPLAN_API_KEY",), - base_url_env_var="TOKENPLAN_BASE_URL", + "bedrock": _api_key_provider( + "bedrock", "AWS Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", + (), "BEDROCK_BASE_URL", auth_type="aws_sdk", ), - "ollama-cloud": ProviderConfig( - id="ollama-cloud", - name="Ollama Cloud", - auth_type="api_key", - inference_base_url=DEFAULT_OLLAMA_CLOUD_BASE_URL, - api_key_env_vars=("OLLAMA_API_KEY",), - base_url_env_var="OLLAMA_BASE_URL", - ), - "bedrock": ProviderConfig( - id="bedrock", - name="AWS Bedrock", - auth_type="aws_sdk", - inference_base_url="https://bedrock-runtime.us-east-1.amazonaws.com", - api_key_env_vars=(), - base_url_env_var="BEDROCK_BASE_URL", - ), - "vertex": ProviderConfig( - id="vertex", - name="Google Vertex AI", - auth_type="vertex", - # 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. - inference_base_url="", - api_key_env_vars=(), # OAuth2 (service-account JSON / ADC), not a key - base_url_env_var="", - ), - "azure-foundry": ProviderConfig( - id="azure-foundry", - name="Azure Foundry", - auth_type="api_key", - inference_base_url="", # User-provided endpoint - api_key_env_vars=("AZURE_FOUNDRY_API_KEY",), - base_url_env_var="AZURE_FOUNDRY_BASE_URL", + # 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", ), } @@ -631,13 +510,10 @@ except Exception: 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`` + 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: - - ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN """ from hermes_cli.config import get_env_value_prefer_dotenv @@ -668,8 +544,8 @@ 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 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 @@ -681,7 +557,6 @@ def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> return default_url - _PLACEHOLDER_SECRET_VALUES = { "*", "**", @@ -705,9 +580,7 @@ def has_usable_secret(value: Any, *, min_length: int = 4) -> bool: cleaned = value.strip() if len(cleaned) < min_length: return False - if cleaned.lower() in _PLACEHOLDER_SECRET_VALUES: - return False - return True + return cleaned.lower() not in _PLACEHOLDER_SECRET_VALUES # Known API-key prefixes per provider. Only providers listed here get @@ -724,9 +597,8 @@ KNOWN_PROVIDER_KEY_PREFIXES: Dict[str, tuple] = { 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. + 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: @@ -837,9 +709,8 @@ def _probe_single_zai_endpoint( ) -> 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). + 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: @@ -875,10 +746,9 @@ def _probe_single_zai_endpoint( 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. + 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 @@ -925,10 +795,9 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str 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 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 @@ -989,10 +858,9 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> 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``. + 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"): @@ -1029,13 +897,27 @@ class AuthError(RuntimeError): 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 - exhaustion rather than missing or invalid credentials. - These failures are transient — re-authenticating cannot resolve them — so - callers should surface a "retry later" notice and prefer a fallback chain - instead of prompting the operator to run ``hermes auth``. + 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) @@ -1045,12 +927,7 @@ def is_rate_limited_auth_error(error: Exception) -> bool: def _parse_retry_after_seconds(headers: Any) -> Optional[int]: - """Best-effort parse of a ``Retry-After`` header into whole seconds. - - Thin wrapper around :func:`agent.retry_utils.parse_retry_after_seconds` - (delta-seconds and HTTP-date forms; negatives clamp to 0; missing or - unparseable values return ``None``). - """ + """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) @@ -1070,19 +947,12 @@ def format_auth_error(error: Exception) -> str: if error.relogin_required: return f"{error} Run `hermes model` to re-authenticate." - if error.code == "subscription_required": - if error.provider == "nous": - return _format_nous_entitlement_auth_error(error) - return "No active paid subscription found. Please purchase/activate a subscription, then retry." - - if error.code == "insufficient_credits": - if error.provider == "nous": - return _format_nous_entitlement_auth_error(error) - return "Subscription credits are exhausted. Top up/renew credits, then retry." - - if error.code in {"subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded"}: + 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." @@ -1090,6 +960,17 @@ def format_auth_error(error: Exception) -> str: 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 ( @@ -1109,6 +990,10 @@ def _format_nous_entitlement_auth_error(error: AuthError) -> str: 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): @@ -1163,12 +1048,9 @@ def _auth_file_path() -> 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. - - See issue #18594 follow-up (credential_pool shadowing). + 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 @@ -1194,19 +1076,8 @@ def _global_auth_file_path() -> Optional[Path]: 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. - - Memoised keyed on the global auth file's path + mtime (same pattern as - ``_nous_auth_status_cache``): read_credential_pool() -> load_pool() runs - this once per provider row in the /model picker, and the path resolution - (``_global_auth_file_path()`` -> ``get_default_hermes_root()``) + JSON - parse cost ~105us+ per call even when nothing changed. The global - store only changes when the user authenticates at global scope (writes - always go through _save_auth_store, which touches the file), so the mtime - key keeps the memo freshness-correct. Callers must treat the returned - store as read-only (all current callers do — .get / dict() / list() - copies only). + 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() @@ -1279,12 +1150,9 @@ def _file_lock( ): """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. profile auth.json vs shared Nous store) don't share reentrancy - state — that would let one lock's reentrant acquisition silently skip - the other's kernel-level flock. + 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 @@ -1362,15 +1230,14 @@ def _auth_store_lock( ): """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. + ``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. + 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() @@ -1452,28 +1319,27 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: return {"version": AUTH_STORE_VERSION, "providers": {}} -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_file.parent.mkdir(parents=True, exist_ok=True) - # Tighten parent dir to 0o700 so siblings can't traverse to creds. - # No-op on Windows (POSIX mode bits not enforced); ignore failures. +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(auth_file) - auth_store["version"] = AUTH_STORE_VERSION - auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() - payload = json.dumps(auth_store, indent=2) + "\n" - tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + secure_parent_dir(target) + tmp_path = target.with_name(f"{target.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: - # Create with 0o600 atomically via os.open(O_EXCL) + fdopen to close - # the TOCTOU window where default umask (often 0o644) briefly exposed - # OAuth tokens to other local users between open() and chmod(). - # Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148). fd = os.open( str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, @@ -1483,22 +1349,37 @@ def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = N handle.write(payload) handle.flush() os.fsync(handle.fileno()) - atomic_replace(tmp_path, auth_file) - try: - dir_fd = os.open(str(auth_file.parent), os.O_RDONLY) - except OSError: - dir_fd = None - if dir_fd is not None: + (replace or atomic_replace)(tmp_path, target) + if fsync_dir: try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) + 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) @@ -1513,38 +1394,36 @@ def _load_provider_state_with_source( ) -> 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. In profile mode ``_load_provider_state`` can read a - global-root fallback state; persisting a rotated Nous refresh token only to - the profile would leave the global/root store stale and cause the next - process to replay an already-consumed refresh token. + 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. """ - providers = auth_store.get("providers") + 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), _auth_file_path() - - global_path = _global_auth_file_path() - global_store = _load_global_auth_store() - if global_store: - global_providers = global_store.get("providers") - if isinstance(global_providers, dict): - global_state = global_providers.get(provider_id) - if isinstance(global_state, dict): - return dict(global_state), global_path - return None, None + 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. + 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() @@ -1558,38 +1437,33 @@ def _provider_state_transaction(provider_id: str): return with _auth_store_lock(target_path=source_path): - source_store = _load_auth_store(source_path) - source_providers = source_store.get("providers") - source_state = None - if isinstance(source_providers, dict): - raw_state = source_providers.get(provider_id) - if isinstance(raw_state, dict): - source_state = dict(raw_state) + 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. Once the user runs ``hermes auth login `` inside - the profile, the profile state fully shadows the global state on the next - read. See issue #18594 follow-up. + 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: - providers = auth_store.setdefault("providers", {}) - if not isinstance(providers, dict): - auth_store["providers"] = {} - providers = auth_store["providers"] - providers[provider_id] = state - auth_store["active_provider"] = provider_id + """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( @@ -1600,13 +1474,7 @@ def _save_provider_state_to_source( ) -> None: """Persist provider state back to the auth store it was read from.""" active_path = _auth_file_path() - if source_path is None: - source_path = active_path - try: - same_store = source_path.resolve(strict=False) == active_path.resolve(strict=False) - except Exception: - same_store = source_path == active_path - if same_store: + 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 @@ -1657,13 +1525,9 @@ def _persist_provider_state_to_store( 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 create credential-pool - entries directly (no singleton ``providers.`` block). Adding the - very first credential for a provider should make it the active provider - so the setup wizard's ``_model_section_has_credentials()`` check (which - consults ``get_active_provider()``) does not report "No inference - provider configured". Subsequent adds for an already-active setup leave - the user's chosen active provider untouched. + 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() @@ -1687,9 +1551,8 @@ def get_auth_provider_display_name(provider_id: str) -> str: def is_runtime_provider_routable(provider_id: str) -> bool: """Return whether runtime resolution recognizes a provider identity. - This is a capability check, not a credential check. It follows the same - alias/plugin-aware normalization as ``resolve_provider`` while preserving - special runtime identities that intentionally live outside the registry. + 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: @@ -1741,19 +1604,13 @@ def _is_oauth_pool_payload(entry: Any) -> bool: 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. OAuth rows for the providers in - ``SINGLE_USE_REFRESH_POOL_PROVIDERS``, the matching ``providers.`` - device-code blocks, and the ``.anthropic_oauth.json`` singleton are - dropped so the clone reads the grant from the global root instead of - holding its own doomed copy (#100339). + 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. + 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) @@ -1855,8 +1712,8 @@ _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. + ``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() @@ -1866,9 +1723,9 @@ def consume_oauth_heal_notices() -> List[str]: 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 no - claims (returns None — lineage then rests on id / token material). + 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 @@ -1879,7 +1736,7 @@ def _oauth_identity(entry: Dict[str, Any]) -> Optional[str]: 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 isinstance(value, str) and value.strip(): + if _nonempty_str(value): return value.strip() return None @@ -1887,9 +1744,9 @@ def _oauth_identity(entry: Dict[str, Any]) -> Optional[str]: 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. + 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 @@ -1911,14 +1768,9 @@ def _find_root_counterpart( ) -> Optional[int]: """Index of the root OAuth row that shares a grant lineage with *profile_row*. - Strongest evidence first: same pool ``id`` (clone-all and the pre-fix - borrowed-persist both preserved it), same account identity from JWT - claims, same token material (an unrotated copy). 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. - Only a row whose identity claims name a DIFFERENT account is left alone - (an independent ``hermes -p

auth add`` login for another account). + 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: @@ -1934,7 +1786,7 @@ def _find_root_counterpart( return i for key in ("refresh_token", "access_token"): p_val = profile_row.get(key) - if not (isinstance(p_val, str) and p_val.strip()): + if not _nonempty_str(p_val): continue for i in candidates: if root_rows[i].get(key) == p_val: @@ -1980,10 +1832,9 @@ def _singleton_as_row(path: Path) -> Optional[Dict[str, Any]]: 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. + 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 @@ -1994,6 +1845,38 @@ def heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, 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.`` 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: @@ -2090,28 +1973,13 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, # ── providers. device-code blocks (Codex / xAI) ───────── if provider_id in ("openai-codex", "xai-oauth"): - p_providers = profile_store.get("providers") - r_providers = root_store.get("providers") - if isinstance(p_providers, dict) and isinstance(r_providers, dict): - p_block = p_providers.get(provider_id) - r_block = r_providers.get(provider_id) - else: - p_block = r_block = None - if isinstance(p_block, dict) and p_block and isinstance(r_block, dict) and r_block: - p_tokens = p_block.get("tokens") if isinstance(p_block.get("tokens"), dict) else {} - r_tokens = r_block.get("tokens") if isinstance(r_block.get("tokens"), dict) else {} - p_flat = {**p_tokens, "last_refresh": p_block.get("last_refresh")} - r_flat = {**r_tokens, "last_refresh": r_block.get("last_refresh")} - p_ident, r_ident = _oauth_identity(p_flat), _oauth_identity(r_flat) - same_account = (p_ident == r_ident) if (p_ident and r_ident) else True - if same_account: - if _oauth_freshness(p_flat) > _oauth_freshness(r_flat): - r_providers[provider_id] = dict(p_block) - root_changed = True - summary["adopted"] = True - del p_providers[provider_id] - profile_changed = True - summary["providers_block"] = True + 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(): @@ -2206,18 +2074,13 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, 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. + In profile mode, the profile's credential pool is authoritative. If a provider has no entries in + the profile, entries from the global-root ``auth.json`` are used as a read-only fallback — so + workers spawned in a profile can see providers that were only authenticated at global scope. - Profile entries always win: the global fallback only applies per-provider - when the profile has zero entries for that provider. Once the user runs - ``hermes auth add `` inside the profile, profile entries - fully shadow global for that provider on the next read. - - Writes always go to the profile (``write_credential_pool`` is unchanged). - See issue #18594 follow-up. + Profile entries always win: the global fallback only applies per-provider when the profile has + zero entries for that provider. Once the user runs ``hermes auth add `` inside the + profile, profile entries fully shadow global for that provider on the next read. """ auth_store = _load_auth_store() pool = auth_store.get("credential_pool") @@ -2260,6 +2123,12 @@ _POOL_STATUS_FIELDS = ( ) +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]], @@ -2267,15 +2136,10 @@ def _merge_disk_cooldown_state( ) -> 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. Adopt the on-disk status fields only when they are - strictly more recent (by ``last_status_at``) AND still binding — a DEAD - marker, or an EXHAUSTED cooldown that has not yet expired. Expired - cooldowns are not resurrected, so the pool's own expiry-clear (which - resets ``last_status_at`` to None) is never overridden. + ``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 @@ -2325,21 +2189,13 @@ def write_credential_pool( ) -> 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. + 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. - - For entries present on BOTH sides, status fields are merged by - ``last_status_at`` recency via ``_merge_disk_cooldown_state`` so a stale - snapshot cannot erase a cooldown/quarantine another process just wrote. - - Pass ``removed_ids`` for entries the caller intentionally removed, so the - merge does not resurrect them from the on-disk copy. + 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(): @@ -2387,9 +2243,9 @@ def write_credential_pool( 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. + 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() @@ -2397,22 +2253,27 @@ def suppress_credential_source(provider_id: str, source: str) -> None: if not isinstance(suppressed, dict): suppressed = {} auth_store["suppressed_sources"] = suppressed - - raw_sources = suppressed.get(provider_id) - if isinstance(raw_sources, list): - provider_list = raw_sources - elif isinstance(raw_sources, dict): - provider_list = [str(name) for name in raw_sources] - suppressed[provider_id] = provider_list - else: + 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: @@ -2424,24 +2285,14 @@ def is_source_suppressed(provider_id: str, source: str) -> bool: 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. - - Returns True if a marker was cleared, False if no marker existed. - """ + """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 - raw_sources = suppressed.get(provider_id) - if isinstance(raw_sources, dict): - provider_list = [str(name) for name in raw_sources] - suppressed[provider_id] = provider_list - elif isinstance(raw_sources, list): - provider_list = raw_sources - else: - return False - if source not in provider_list: + 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: @@ -2455,15 +2306,9 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool: 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. Writes (``_save_auth_store`` / ``persist_*_credentials``) - are unchanged — they still target the profile only. This mirrors - ``read_credential_pool``'s per-provider shadowing semantics so that - ``_seed_from_singletons`` can reseed a profile's credential pool from - global-scope provider state (e.g. a globally-authenticated Anthropic - OAuth or Nous device-code session). See issue #18594 follow-up. + 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) @@ -2478,15 +2323,8 @@ def get_active_provider() -> Optional[str]: def is_provider_explicitly_configured(provider_id: str) -> bool: """Return True only if the user has explicitly configured this provider. - Checks: - 1. active_provider in auth.json matches - 2. model.provider in config.yaml matches - 3. Provider-specific env vars are set (e.g. ANTHROPIC_API_KEY) - - This is used to gate auto-discovery of external credentials (e.g. - Claude Code's ~/.claude/.credentials.json) so they are never used - without the user's explicit choice. See PR #4210 for the same - pattern applied to the setup wizard gate. + Claude Code's ~/.claude/.credentials.json) so they are never used without the user's explicit + choice. """ normalized = (provider_id or "").strip().lower() @@ -2522,85 +2360,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool: 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): - for slot in moa_cfg.get("reference_models") or []: - if _slot_matches_provider(slot): - return True - if _slot_matches_provider(moa_cfg.get("aggregator")): + if _moa_block_matches(moa_cfg): return True presets = moa_cfg.get("presets") - if isinstance(presets, dict): - for preset in presets.values(): - if not isinstance(preset, dict): - continue - for slot in preset.get("reference_models") or []: - if _slot_matches_provider(slot): - return True - if _slot_matches_provider(preset.get("aggregator")): - return True + if isinstance(presets, dict) and any(_moa_block_matches(p) for p in presets.values()): + return True except Exception: pass - # 3. Check provider-specific env vars - # Exclude CLAUDE_CODE_OAUTH_TOKEN — it's set by Claude Code itself, - # not by the user explicitly configuring anthropic in Hermes. - _IMPLICIT_ENV_VARS = {"CLAUDE_CODE_OAUTH_TOKEN"} - pconfig = PROVIDER_REGISTRY.get(normalized) - # Fallback to ProviderDef from models.dev catalog when the provider - # isn't in the manually-maintained PROVIDER_REGISTRY (e.g. openrouter). - # Both expose .auth_type and .api_key_env_vars with the same shape. - if pconfig is None: - from hermes_cli.providers import get_provider - pconfig = get_provider(normalized) - if pconfig and pconfig.auth_type == "api_key": - for env_var in pconfig.api_key_env_vars: - if env_var in _IMPLICIT_ENV_VARS: - continue - if has_usable_secret(os.getenv(env_var, "")): - return True - - # AWS SDK providers (Bedrock) have auth_type="aws_sdk" and empty - # api_key_env_vars, so the loop above never sees them. A user who sets - # AWS_BEARER_TOKEN_BEDROCK (or an access-key pair) in .env has configured - # the provider exactly as explicitly as pasting ANTHROPIC_API_KEY — - # without this check the desktop picker's explicit_only filter hides - # Bedrock even though list_authenticated_providers builds its row. - # Only check explicit env credentials here (NOT boto3's full chain): - # ambient sources like EC2 IMDS / SSO profiles must not auto-surface. - if pconfig and pconfig.auth_type == "aws_sdk": - if has_usable_secret(os.getenv("AWS_BEARER_TOKEN_BEDROCK", "")): - return True - if ( - has_usable_secret(os.getenv("AWS_ACCESS_KEY_ID", "")) - and has_usable_secret(os.getenv("AWS_SECRET_ACCESS_KEY", "")) - ): - return True + # 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: - for entry in read_credential_pool(normalized): - if not isinstance(entry, dict): - continue - source = str(entry.get("source") or "").strip().lower() - if not source: - continue - 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() - if env_var and has_usable_secret(os.getenv(env_var, "")): - return True - continue - if ( - source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"} - or source.startswith("manual:") - ): - return True + if any(_pool_entry_is_explicit(entry) for entry in read_credential_pool(normalized)): + return True except Exception: pass @@ -2613,38 +2399,90 @@ def is_provider_explicitly_configured(provider_id: str) -> bool: # 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. - # - # NOTE: this 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. try: - if normalized in ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"): - from agent.vertex_adapter import has_explicit_vertex_config - - if has_explicit_vertex_config(): - return True - elif normalized == "bedrock": - from hermes_cli.config import load_config as _load_cfg - - bedrock_cfg = _load_cfg().get("bedrock") - if isinstance(bedrock_cfg, dict) and str( - bedrock_cfg.get("region") or "" - ).strip(): - return True + 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 -def clear_provider_auth(provider_id: Optional[str] = None) -> bool: +# 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. """ - 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. + 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() @@ -2681,10 +2519,9 @@ def clear_provider_auth(provider_id: Optional[str] = None) -> bool: 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. + """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() @@ -2698,11 +2535,7 @@ def deactivate_provider() -> None: def _get_config_hint_for_unknown_provider(provider_name: str) -> str: - """Return a helpful hint string when provider resolution fails. - - Checks for common config.yaml mistakes (malformed custom_providers, etc.) - and returns a human-readable diagnostic, or empty string if nothing found. - """ + """Return a helpful hint string when provider resolution fails.""" try: from hermes_cli.config import validate_config_structure issues = validate_config_structure() @@ -2725,16 +2558,13 @@ def _get_config_hint_for_unknown_provider(provider_name: str) -> str: 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. ``openai-codex``). That is - silent real-money spend against the user's actual intent (#81952). + 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). + 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 @@ -2754,79 +2584,130 @@ def _refuse_env_adoption_if_config_corrupt() -> None: ) +# Provider aliases accepted by resolve_provider(). Plugin-declared aliases +# (plugins/model-providers//) are layered on at call time; this hardcoded +# table remains authoritative for existing names. +_PROVIDER_ALIASES: Dict[str, str] = { + "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", + "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", + "x-ai": "xai", "x.ai": "xai", "grok": "xai", + "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", + "grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", + "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", + "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", + "step": "stepfun", "stepfun-coding-plan": "stepfun", + "arcee-ai": "arcee", "arceeai": "arcee", + "gmi-cloud": "gmi", "gmicloud": "gmi", + "actual-computer": "actual", "actualcomputer": "actual", "aci": "actual", + "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", + "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth", + "alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan", + "alibaba_coding_plan": "alibaba-coding-plan", + "claude": "anthropic", "claude-code": "anthropic", + "github": "copilot", "github-copilot": "copilot", + "github-models": "copilot", "github-model": "copilot", + "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", + "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", + "opencode": "opencode-zen", "zen": "opencode-zen", + "free": "opencode-free", "opencode_free": "opencode-free", + "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", + "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", + "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub", + "tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub", + "tokenplan": "tencent-tokenplan", "tencent-lkeap": "tencent-tokenplan", + "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock", + "go": "opencode-go", "opencode-go-sub": "opencode-go", + "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", + "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio", + # Local server aliases — route through the generic custom provider + "ollama": "custom", "ollama_cloud": "ollama-cloud", + "vllm": "custom", "llamacpp": "custom", + "llama.cpp": "custom", "llama-cpp": "custom", +} + + +def _scoped_key_env_reader() -> Callable[[str], str]: + """Scope-aware key reader for provider auto-detection. + + Under multiplex a secondary profile's API keys live only in its secret scope, not os.environ — + a bare getenv would find nothing and auto-resolution would report "No LLM provider configured" + for every secondary profile (same class as #86905). Catch ONLY ImportError: any other failure + inside auxiliary_client must propagate — silently falling back to os.getenv would reintroduce + the very fail-open this removes, with zero trace. + """ + try: + from agent.auxiliary_client import _scoped_key_env + return _scoped_key_env + except ImportError: + logger.warning( + "agent.auxiliary_client unavailable (%s); provider auto-detection " + "will read keys from the process environment only — under " + "multiplex, secondary profiles may report 'No LLM provider'.", + "import failed", + ) + return lambda name: os.getenv(name) or "" + + +def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: + """True when an OpenRouter credential exists via env key or the credential pool. + + The pool check covers a key added via `hermes auth add openrouter` (manual pool entry, no env + var). Without it, a pool-only key is invisible to auto-detection — `hermes auth list` shows the + credential while requests go out with no Authorization header (#42130). + """ + if has_usable_secret(scoped_key_env("OPENAI_API_KEY")) or has_usable_secret( + scoped_key_env("OPENROUTER_API_KEY") + ): + return True + try: + from agent.credential_pool import load_pool as _load_pool + + return bool(_load_pool("openrouter").has_credentials()) + except Exception as e: + logger.debug("Could not check OpenRouter credential pool: %s", e) + return False + + +def _logged_in_oauth_active_provider() -> Optional[str]: + """auth.json ``active_provider`` when it is a registry provider that reports logged in.""" + try: + _store = _load_auth_store() + _maybe = _store.get("active_provider") + if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"): + return _maybe + except Exception as e: + logger.debug("Could not pre-read active auth provider: %s", e) + return None + + def resolve_provider( requested: Optional[str] = None, *, explicit_api_key: Optional[str] = None, explicit_base_url: Optional[str] = None, ) -> str: - """ - Determine which inference provider to use. + """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. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider - 6. auth.json `active_provider` (logged-in OAuth) — last-resort fallback - 7. AWS Bedrock credential chain - 8. Error (no provider configured) + 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 - _PROVIDER_ALIASES = { - "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", - } - # Extend with aliases declared in plugins/model-providers// that aren't already mapped. - # This keeps providers/ as the single source for new aliases while the - # hardcoded dict above remains authoritative for existing ones. + # Normalize provider aliases. Extend with aliases declared in + # plugins/model-providers// that aren't already mapped. + aliases = dict(_PROVIDER_ALIASES) try: from providers import list_providers as _lp for _pp in _lp(): for _alias in _pp.aliases: - if _alias not in _PROVIDER_ALIASES: - _PROVIDER_ALIASES[_alias] = _pp.name + if _alias not in aliases: + aliases[_alias] = _pp.name except Exception: pass - normalized = _PROVIDER_ALIASES.get(normalized, normalized) + normalized = aliases.get(normalized, normalized) if normalized == "openrouter": return "openrouter" @@ -2869,61 +2750,17 @@ def resolve_provider( except Exception as e: logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e) - # Scope-aware key reads: under multiplex a secondary profile's API keys - # live only in its secret scope, not os.environ — a bare getenv here - # 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 PR removes, with zero trace. - try: - from agent.auxiliary_client import _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", - ) + _scoped_key_env = _scoped_key_env_reader() - def _scoped_key_env(name: str) -> str: - return os.getenv(name) or "" - - if has_usable_secret(_scoped_key_env("OPENAI_API_KEY")) or has_usable_secret( - _scoped_key_env("OPENROUTER_API_KEY") - ): - _refuse_env_adoption_if_config_corrupt() - return "openrouter" - - # Auto-detect an OpenRouter credential added via `hermes auth add openrouter` - # (manual pool entry, no env var). Without this, a key that only lives in - # the credential pool is invisible to auto-detection — the user sees - # `hermes auth list` showing the credential while requests go out with no - # Authorization header ("HTTP 401: Missing Authentication header"). The - # env-var check above only covers keys exported as OPENROUTER_API_KEY / - # OPENAI_API_KEY. See issue #42130. - try: - from agent.credential_pool import load_pool as _load_pool - - _pool_has_creds = _load_pool("openrouter").has_credentials() - except Exception as e: - _pool_has_creds = False - logger.debug("Could not check OpenRouter credential pool: %s", e) - if _pool_has_creds: + # 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: Optional[str] = None - 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"): - _oauth_active = _maybe - except Exception as e: - logger.debug("Could not pre-read active auth provider: %s", e) + _oauth_active = _logged_in_oauth_active_provider() # Auto-detect API-key providers by checking their env vars for pid, pconfig in PROVIDER_REGISTRY.items(): @@ -2991,6 +2828,11 @@ def resolve_provider( # 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 @@ -3015,6 +2857,62 @@ def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool: 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) @@ -3075,24 +2973,9 @@ _ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: """Validate a Portal-returned inference URL against the host allowlist. - Returns ``url`` (normalised by stripping trailing slashes) if it's a - well-formed ``https:///...`` URL. Returns ``None`` - if the URL is missing, malformed, non-https, or points at an - unexpected host — letting the caller fall back to the configured - default rather than persist or forward a poisoned value. - - 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. - Validating scheme + host at the source closes that loop before the - poisoned URL ever lands in ``auth.json``. - - The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses - this — env values come from the trusted OS user, not from the - network, and the override is documented for staging/dev use. - - Co-authored-by: memosr + 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 @@ -3122,12 +3005,9 @@ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[st def _nous_inference_env_override() -> Optional[str]: """Return the user-set ``NOUS_INFERENCE_BASE_URL`` override, if any. - This is the documented dev/staging escape hatch. The env source is - trusted (the OS user set it themselves), so it is intentionally NOT - gated by the network host allowlist — unlike Portal-returned URLs. - - Returns a trailing-slash-stripped non-empty string, or ``None`` when - the env var is unset/blank. + 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")) @@ -3135,19 +3015,10 @@ def _nous_inference_env_override() -> Optional[str]: def _nous_portal_env_override() -> Optional[str]: """Return the user/deployment-set Portal base URL override, if any. - Mirrors ``_nous_inference_env_override()``: ``HERMES_PORTAL_BASE_URL`` / - ``NOUS_PORTAL_BASE_URL`` are the documented dev/staging escape hatch for - pointing Hermes at a non-production Nous Portal (e.g. a hosted agent - provisioned on nous-account-service's `staging` environment, which stamps - ``HERMES_PORTAL_BASE_URL=https://portal.staging-nousresearch.com`` into - the container env). The env source is trusted (the OS user/deployment - set it themselves), so — like the inference override — it must NOT be - gated by ``_NOUS_PORTAL_ALLOWED_HOSTS``: that allowlist exists to reject - an untrusted NETWORK-provided value (a poisoned portal_base_url - persisted to auth.json), not a value the operator explicitly configured. - - Returns a trailing-slash-stripped non-empty string, or ``None`` when - neither env var is set/blank. + ``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") @@ -3243,12 +3114,10 @@ def _assert_nous_inference_jwt_usable( ) if reason is None: return - raise AuthError( + raise _nous_err( "Nous Portal access token is not a usable inference JWT " f"({reason}). Re-authenticate with: hermes auth add nous", - provider="nous", - code=reason, - relogin_required=True, + reason, relogin=True, ) @@ -3282,7 +3151,7 @@ def _set_nous_agent_key_from_invoke_jwt( obtained_at: Optional[str] = None, ) -> None: access_token = state.get("access_token") - if not isinstance(access_token, str) or not access_token.strip(): + if not _nonempty_str(access_token): return now = datetime.now(timezone.utc) existing_obtained_at = state.get("agent_key_obtained_at") @@ -3320,7 +3189,7 @@ def _select_nous_invoke_jwt( access_token: Any = None, sequence_id: Optional[str] = None, ) -> None: - if isinstance(access_token, str) and access_token.strip(): + if _nonempty_str(access_token): state["access_token"] = access_token _set_nous_agent_key_from_invoke_jwt(state) _log_nous_invoke_jwt_selected( @@ -3361,57 +3230,25 @@ def _qwen_cli_auth_path() -> Path: def _read_qwen_cli_tokens() -> Dict[str, Any]: auth_path = _qwen_cli_auth_path() if not auth_path.exists(): - raise AuthError( + raise _qwen_err( "Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.", - provider="qwen-oauth", - code="qwen_auth_missing", + "qwen_auth_missing", ) try: data = json.loads(auth_path.read_text(encoding="utf-8")) except Exception as exc: - raise AuthError( + raise _qwen_err( f"Failed to read Qwen CLI credentials from {auth_path}: {exc}", - provider="qwen-oauth", - code="qwen_auth_read_failed", + "qwen_auth_read_failed", ) from exc if not isinstance(data, dict): - raise AuthError( - f"Invalid Qwen CLI credentials in {auth_path}.", - provider="qwen-oauth", - code="qwen_auth_invalid", - ) + 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() - auth_path.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(auth_path) - # Per-process random temp suffix avoids collisions between concurrent - # writers and stale leftovers from a crashed prior write. - tmp_path = auth_path.with_name(f"{auth_path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") - # Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU - # window where write_text() + post-write chmod briefly exposed tokens - # at process umask (typically 0o644). See #19673, #21148. - fd = os.open( - str(tmp_path), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - stat.S_IRUSR | stat.S_IWUSR, - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(json.dumps(tokens, indent=2, sort_keys=True) + "\n") - fh.flush() - os.fsync(fh.fileno()) - atomic_replace(tmp_path, auth_path) - finally: - try: - if tmp_path.exists(): - tmp_path.unlink() - except OSError: - pass + _write_private_file_atomic(auth_path, json.dumps(tokens, indent=2, sort_keys=True) + "\n") return auth_path @@ -3426,19 +3263,15 @@ def _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN 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 AuthError( + raise _qwen_err( "Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.", - provider="qwen-oauth", - code="qwen_refresh_token_missing", + "qwen_refresh_token_missing", ) try: response = httpx.post( QWEN_OAUTH_TOKEN_URL, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, + headers=_FORM_JSON_HEADERS, data={ "grant_type": "refresh_token", "refresh_token": refresh_token, @@ -3447,35 +3280,28 @@ def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20 timeout=timeout_seconds, ) except Exception as exc: - raise AuthError( - f"Qwen OAuth refresh failed: {exc}", - provider="qwen-oauth", - code="qwen_refresh_failed", - ) from 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 AuthError( + raise _qwen_err( "Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'." + (f" Response: {body}" if body else ""), - provider="qwen-oauth", - code="qwen_refresh_failed", + "qwen_refresh_failed", ) try: payload = response.json() except Exception as exc: - raise AuthError( + raise _qwen_err( f"Qwen OAuth refresh returned invalid JSON: {exc}", - provider="qwen-oauth", - code="qwen_refresh_invalid_json", + "qwen_refresh_invalid_json", ) from exc if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip(): - raise AuthError( + raise _qwen_err( "Qwen OAuth refresh response missing access_token.", - provider="qwen-oauth", - code="qwen_refresh_invalid_response", + "qwen_refresh_invalid_response", ) expires_in = payload.get("expires_in") @@ -3498,12 +3324,9 @@ def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20 def _mark_qwen_oauth_active(creds: Dict[str, Any]) -> None: """Set active_provider to qwen-oauth in auth.json. - Qwen OAuth tokens live in the Qwen CLI credential file managed by - _save_qwen_cli_tokens / resolve_qwen_runtime_credentials. This function - only writes a minimal provider-state entry (base_url for display) and - sets active_provider so that get_active_provider() and - _model_section_has_credentials() detect the provider for the setup wizard - and status commands. + 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() @@ -3529,10 +3352,9 @@ def resolve_qwen_runtime_credentials( tokens = _refresh_qwen_cli_tokens(tokens) access_token = str(tokens.get("access_token", "") or "").strip() if not access_token: - raise AuthError( + raise _qwen_err( "Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.", - provider="qwen-oauth", - code="qwen_access_token_missing", + "qwen_access_token_missing", ) base_url = os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL @@ -3589,26 +3411,45 @@ def _spotify_scope_string(raw_scope: Optional[str] = None) -> str: return " ".join(_spotify_scope_list(raw_scope)) -def _spotify_client_id( +def _spotify_setting( + state: Optional[Dict[str, Any]], + state_key: str, + env_vars: Tuple[str, ...], + default: str, + *, explicit: Optional[str] = None, - state: Optional[Dict[str, Any]] = 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("HERMES_SPOTIFY_CLIENT_ID"), - get_env_value("SPOTIFY_CLIENT_ID"), - state.get("client_id") if isinstance(state, dict) else None, + *(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 - raise AuthError( + 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.", - provider="spotify", - code="spotify_client_id_missing", + "spotify_client_id_missing", ) @@ -3616,50 +3457,24 @@ def _spotify_redirect_uri( explicit: Optional[str] = None, state: Optional[Dict[str, Any]] = None, ) -> str: - from hermes_cli.config import get_env_value - - candidates = ( - explicit, - get_env_value("HERMES_SPOTIFY_REDIRECT_URI"), - get_env_value("SPOTIFY_REDIRECT_URI"), - state.get("redirect_uri") if isinstance(state, dict) else None, - DEFAULT_SPOTIFY_REDIRECT_URI, + return _spotify_setting( + state, "redirect_uri", ("HERMES_SPOTIFY_REDIRECT_URI", "SPOTIFY_REDIRECT_URI"), + DEFAULT_SPOTIFY_REDIRECT_URI, explicit=explicit, ) - for candidate in candidates: - cleaned = str(candidate or "").strip() - if cleaned: - return cleaned - return DEFAULT_SPOTIFY_REDIRECT_URI def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str: - from hermes_cli.config import get_env_value - - candidates = ( - get_env_value("HERMES_SPOTIFY_API_BASE_URL"), - state.get("api_base_url") if isinstance(state, dict) else None, - DEFAULT_SPOTIFY_API_BASE_URL, + return _spotify_setting( + state, "api_base_url", ("HERMES_SPOTIFY_API_BASE_URL",), + DEFAULT_SPOTIFY_API_BASE_URL, strip_slash=True, ) - for candidate in candidates: - cleaned = str(candidate or "").strip().rstrip("/") - if cleaned: - return cleaned - return DEFAULT_SPOTIFY_API_BASE_URL def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str: - from hermes_cli.config import get_env_value - - candidates = ( - get_env_value("HERMES_SPOTIFY_ACCOUNTS_BASE_URL"), - state.get("accounts_base_url") if isinstance(state, dict) else None, - DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, + return _spotify_setting( + state, "accounts_base_url", ("HERMES_SPOTIFY_ACCOUNTS_BASE_URL",), + DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, strip_slash=True, ) - for candidate in candidates: - cleaned = str(candidate or "").strip().rstrip("/") - if cleaned: - return cleaned - return DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL def _spotify_code_verifier(length: int = 64) -> str: @@ -3672,16 +3487,6 @@ def _spotify_code_challenge(code_verifier: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") -def _oauth_pkce_code_verifier(length: int = 64) -> str: - raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii") - return raw.rstrip("=")[:128] - - -def _oauth_pkce_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, @@ -3706,23 +3511,20 @@ def _spotify_build_authorize_url( def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]: parsed = urlparse(redirect_uri) if parsed.scheme != "http": - raise AuthError( + raise _spotify_err( "Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.", - provider="spotify", - code="spotify_redirect_invalid", + "spotify_redirect_invalid", ) host = parsed.hostname or "" if host not in {"127.0.0.1", "localhost"}: - raise AuthError( + raise _spotify_err( "Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.", - provider="spotify", - code="spotify_redirect_invalid", + "spotify_redirect_invalid", ) if not parsed.port: - raise AuthError( + raise _spotify_err( "Spotify PKCE redirect_uri must include an explicit localhost port.", - provider="spotify", - code="spotify_redirect_invalid", + "spotify_redirect_invalid", ) return host, parsed.port, parsed.path or "/" @@ -3779,10 +3581,9 @@ def _spotify_wait_for_callback( try: server = _ReuseHTTPServer((host, port), handler_cls) except OSError as exc: - raise AuthError( + raise _spotify_err( f"Could not bind Spotify callback server on {host}:{port}: {exc}", - provider="spotify", - code="spotify_callback_bind_failed", + "spotify_callback_bind_failed", ) from exc thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) @@ -3797,10 +3598,9 @@ def _spotify_wait_for_callback( server.shutdown() server.server_close() thread.join(timeout=1.0) - raise AuthError( + raise _spotify_err( "Spotify authorization timed out waiting for the local callback.", - provider="spotify", - code="spotify_callback_timeout", + "spotify_callback_timeout", ) @@ -3840,6 +3640,42 @@ def _spotify_token_payload_to_state( 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, @@ -3849,42 +3685,21 @@ def _spotify_exchange_code_for_tokens( accounts_base_url: str, timeout_seconds: float = 20.0, ) -> Dict[str, Any]: - try: - response = httpx.post( - f"{accounts_base_url}/api/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "client_id": client_id, - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - }, - timeout=timeout_seconds, - ) - except Exception as exc: - raise AuthError( - f"Spotify token exchange failed: {exc}", - provider="spotify", - code="spotify_token_exchange_failed", - ) from exc - - if response.status_code >= 400: - detail = response.text.strip() - raise AuthError( - "Spotify token exchange failed." - + (f" Response: {detail}" if detail else ""), - provider="spotify", - code="spotify_token_exchange_failed", - ) - payload = response.json() - if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip(): - raise AuthError( - "Spotify token response did not include an access_token.", - provider="spotify", - code="spotify_token_exchange_invalid", - ) - return payload + 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( @@ -3894,51 +3709,28 @@ def _refresh_spotify_oauth_state( ) -> Dict[str, Any]: refresh_token = str(state.get("refresh_token", "") or "").strip() if not refresh_token: - raise AuthError( + raise _spotify_err( "Spotify refresh token missing. Run `hermes auth spotify` again.", - provider="spotify", - code="spotify_refresh_token_missing", - relogin_required=True, + "spotify_refresh_token_missing", relogin=True, ) client_id = _spotify_client_id(state=state) accounts_base_url = _spotify_accounts_base_url(state) - try: - response = httpx.post( - f"{accounts_base_url}/api/token", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - }, - timeout=timeout_seconds, - ) - except Exception as exc: - raise AuthError( - f"Spotify token refresh failed: {exc}", - provider="spotify", - code="spotify_refresh_failed", - ) from exc - - if response.status_code >= 400: - detail = response.text.strip() - raise AuthError( - "Spotify token refresh failed. Run `hermes auth spotify` again." - + (f" Response: {detail}" if detail else ""), - provider="spotify", - code="spotify_refresh_failed", - relogin_required=True, - ) - - payload = response.json() - if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip(): - raise AuthError( - "Spotify refresh response did not include an access_token.", - provider="spotify", - code="spotify_refresh_invalid", - relogin_required=True, - ) + 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, @@ -3961,11 +3753,9 @@ def resolve_spotify_runtime_credentials( auth_store = _load_auth_store() state = _load_provider_state(auth_store, "spotify") if not state: - raise AuthError( + raise _spotify_err( "Spotify is not authenticated. Run `hermes auth spotify` first.", - provider="spotify", - code="spotify_auth_missing", - relogin_required=True, + "spotify_auth_missing", relogin=True, ) should_refresh = bool(force_refresh) @@ -3978,19 +3768,7 @@ def resolve_spotify_runtime_credentials( _save_auth_store(auth_store) except AuthError as exc: if exc.relogin_required and state.get("refresh_token"): - # Terminal refresh failure — clear dead tokens from auth.json - # so subsequent calls fail fast without a network retry. - # Mirrors the Nous / xAI-OAuth / Codex-OAuth / MiniMax pattern. - for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"): - state.pop(_k, None) - state["last_auth_error"] = { - "provider": "spotify", - "code": exc.code or "refresh_failed", - "message": str(exc), - "reason": "runtime_refresh_failure", - "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } + _quarantine_flat_oauth_state(state, "spotify", exc) try: _store_provider_state(auth_store, "spotify", state, set_active=False) _save_auth_store(auth_store) @@ -4000,11 +3778,9 @@ def resolve_spotify_runtime_credentials( access_token = str(state.get("access_token", "") or "").strip() if not access_token: - raise AuthError( + raise _spotify_err( "Spotify access token missing. Run `hermes auth spotify` again.", - provider="spotify", - code="spotify_access_token_missing", - relogin_required=True, + "spotify_access_token_missing", relogin=True, ) return { @@ -4041,10 +3817,8 @@ def get_spotify_auth_status() -> Dict[str, Any]: 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. - - Raises SystemExit if the user aborts or submits an empty value. + """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 @@ -4204,13 +3978,7 @@ def login_spotify_command(args) -> None: def _is_remote_session() -> bool: """Detect environments where loopback OAuth can't reach the local browser. - Historically only SSH was checked, but #26923 surfaced that - **browser-only remote consoles** (GCP Cloud Shell, GitHub - Codespaces, AWS EC2 Instance Connect, Gitpod, Replit, etc.) hit - the exact same problem — the user has a browser on their laptop - but the loopback listener is bound on the remote VM that the - laptop's browser can't reach. These environments typically don't - set ``SSH_CLIENT`` / ``SSH_TTY``, so the SSH-only check left + 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"): @@ -4254,21 +4022,13 @@ _CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset( 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. This guard distinguishes "a real - windowed browser will pop up" from "a console browser will hijack the - TTY", so callers can fall back to printing the URL instead. + ``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. - * Ask ``webbrowser.get()`` what it resolved to and refuse when the - underlying command is a known console browser. - * macOS and Windows always have a usable default GUI browser. + 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 @@ -4302,17 +4062,14 @@ def _can_open_graphical_browser() -> bool: or getattr(controller, "basename", "") or "" ) - if candidate and _names_console_browser(candidate): - return False - - return True + 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. + 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 @@ -4324,17 +4081,11 @@ def _ssh_user_at_host() -> str: 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:/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. - - The hint is best-effort: silent if we don't think we're remote, or if we - can't parse a host/port out of the redirect URI. - - Pass ``docs_url`` for a provider-specific guide; the generic OAuth-over-SSH - guide is always shown after it. + """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:/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 @@ -4373,48 +4124,55 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) # where one app's refresh invalidates the other's session. # ============================================================================= -def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: - """Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json). - - Returns dict with 'tokens' (access_token, refresh_token) and 'last_refresh'. - Raises AuthError if no Codex tokens are stored. - """ - if _lock: +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(): - auth_store = _load_auth_store() - else: - auth_store = _load_auth_store() + 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 AuthError( + raise _codex_err( "No Codex credentials stored. Run `hermes auth` to authenticate.", - provider="openai-codex", - code="codex_auth_missing", - relogin_required=True, + "codex_auth_missing", relogin=True, ) tokens = state.get("tokens") if not isinstance(tokens, dict): - raise AuthError( + raise _codex_err( "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.", - provider="openai-codex", - code="codex_auth_invalid_shape", - relogin_required=True, + "codex_auth_invalid_shape", relogin=True, ) access_token = tokens.get("access_token") refresh_token = tokens.get("refresh_token") - if not isinstance(access_token, str) or not access_token.strip(): - raise AuthError( + if not _nonempty_str(access_token): + raise _codex_err( "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.", - provider="openai-codex", - code="codex_auth_missing_access_token", - relogin_required=True, + "codex_auth_missing_access_token", relogin=True, ) - if not isinstance(refresh_token, str) or not refresh_token.strip(): - raise AuthError( + if not _nonempty_str(refresh_token): + raise _codex_err( "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", - provider="openai-codex", - code="codex_auth_missing_refresh_token", - relogin_required=True, + "codex_auth_missing_refresh_token", relogin=True, ) return { "tokens": tokens, @@ -4430,56 +4188,21 @@ def _sync_codex_pool_entries( ) -> None: """Mirror a fresh Codex re-auth into the credential_pool OAuth entries. - The runtime selects credentials from ``credential_pool.openai-codex``, not - from ``providers.openai-codex.tokens``. A re-auth invalidates the prior - OAuth pair server-side, but pool entries keep holding the now-consumed - refresh token plus any stale error markers — so the next request spends a - dead token and gets a 401 ``token_invalidated``. + * ``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. - What gets refreshed: - - * ``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. ONLY synced if the - entry's existing access_token matches the *previous* singleton - access_token (i.e. the entry is a legacy singleton-alias from the - #33000 workaround era). Manual entries whose tokens never matched the - singleton represent INDEPENDENT accounts added via - ``hermes auth add openai-codex`` and must not be overwritten by a - re-auth that targeted a different account (regression for #39236). - - The original #33538 fix refreshed every ``manual:device_code`` entry - unconditionally. That worked when ``manual:device_code`` only meant - "legacy alias of the singleton", but the same source string is now - also produced by independent-account additions, and the broad sync - silently clobbered distinct accounts with the latest-authenticated - token pair. The access_token-match check distinguishes the two cases - without changing the source-string contract. - - What does NOT get refreshed: - - * ``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. - * ``manual:device_code`` entries whose access_token does NOT match the - previous singleton — see above; these are independent accounts. - - Error markers (``last_status``, ``last_error_*``) are cleared ONLY on - entries that actually had their tokens rewritten by this re-auth. - Independent entries keep their own error state (their 401/429 markers - belong to that account's own auth flow, not this re-auth). + * ``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") - pool = auth_store.get("credential_pool") - if not isinstance(pool, dict): - return - entries = pool.get("openai-codex") - if not isinstance(entries, list): + 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. @@ -4515,18 +4238,13 @@ def _sync_codex_pool_entries( entry["refresh_token"] = refresh_token if last_refresh: entry["last_refresh"] = last_refresh - entry["last_status"] = None - entry["last_status_at"] = None - entry["last_error_code"] = None - entry["last_error_reason"] = None - entry["last_error_message"] = None - entry["last_error_reset_at"] = None + _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 = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + last_refresh = _utc_now_z() with _auth_store_lock(): auth_store = _load_auth_store() state = _load_provider_state(auth_store, "openai-codex") or {} @@ -4567,21 +4285,69 @@ def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]: 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. Install the same - Happy-Eyeballs sync backend #94388 added for the chat transport. + 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. Proxy-backed transports are - intentionally left on the default backend (the TCP connect goes to the - proxy, not to auth.openai.com/chatgpt.com). + 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: @@ -4593,6 +4359,63 @@ def _codex_http_client(**kwargs: Any) -> "httpx.Client": 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, @@ -4601,12 +4424,10 @@ def refresh_codex_oauth_pure( ) -> 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 isinstance(refresh_token, str) or not refresh_token.strip(): - raise AuthError( + if not _nonempty_str(refresh_token): + raise _codex_err( "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", - provider="openai-codex", - code="codex_auth_missing_refresh_token", - relogin_required=True, + "codex_auth_missing_refresh_token", relogin=True, ) timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) @@ -4633,96 +4454,31 @@ def refresh_codex_oauth_pure( # 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). - retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) - 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." - ) - raise AuthError( - message, - provider="openai-codex", - code=CODEX_RATE_LIMITED_CODE, - relogin_required=False, + raise _codex_quota_exhausted_error( + _parse_retry_after_seconds(getattr(response, "headers", None)) ) if response.status_code != 200: - 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 isinstance(nested_code, str) and nested_code.strip(): - code = nested_code.strip() - nested_msg = err_obj.get("message") - if isinstance(nested_msg, str) and nested_msg.strip(): - message = f"Codex token refresh failed: {nested_msg.strip()}" - # OAuth spec shape: {"error": "code_str", "error_description": "..."} - elif isinstance(err_obj, str) and err_obj.strip(): - code = err_obj.strip() - err_desc = err.get("error_description") or err.get("message") - if isinstance(err_desc, str) and err_desc.strip(): - 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 - raise AuthError( - message, - provider="openai-codex", - code=code, - relogin_required=relogin_required, - ) + raise _codex_refresh_failure_error(response) - try: - refresh_payload = response.json() - except Exception as exc: - raise AuthError( - "Codex token refresh returned invalid JSON.", - provider="openai-codex", - code="codex_refresh_invalid_json", - relogin_required=True, - ) from exc - - refreshed_access = refresh_payload.get("access_token") - if not isinstance(refreshed_access, str) or not refreshed_access.strip(): - raise AuthError( + 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.", - provider="openai-codex", - code="codex_refresh_missing_access_token", - relogin_required=True, - ) + "codex_refresh_missing_access_token", + ), + ) updated = { - "access_token": refreshed_access.strip(), + "access_token": refreshed_access, "refresh_token": refresh_token.strip(), - "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "last_refresh": _utc_now_z(), } next_refresh = refresh_payload.get("refresh_token") - if isinstance(next_refresh, str) and next_refresh.strip(): + if _nonempty_str(next_refresh): updated["refresh_token"] = next_refresh.strip() return updated @@ -4731,10 +4487,7 @@ def _refresh_codex_auth_tokens( tokens: Dict[str, str], timeout_seconds: float, ) -> Dict[str, str]: - """Refresh Codex access token using the refresh token. - - Saves the new tokens to Hermes auth store automatically. - """ + """Refresh Codex access token using the refresh token.""" try: refreshed = refresh_codex_oauth_pure( str(tokens.get("access_token", "") or ""), @@ -4773,9 +4526,8 @@ def _refresh_codex_auth_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. + + 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: @@ -4813,14 +4565,8 @@ def resolve_codex_runtime_credentials( ) -> 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. This - closes the divergence between the chat path (singleton-only via this function) and - the auxiliary path (pool-first via ``_read_codex_access_token``). Without this - fallback, a user whose tokens live only in the pool — for example after a manual - pool seed, a partial re-auth, or pool-only restoration from a backup — gets a bare - HTTP 401 ``Missing Authentication header`` from the wire instead of a usable - credential. See issue #32992. + 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: @@ -4843,18 +4589,7 @@ def resolve_codex_runtime_credentials( if data is None: pool_token = _pool_codex_access_token() if pool_token: - base_url = ( - os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") - or DEFAULT_CODEX_BASE_URL - ) - return { - "provider": "openai-codex", - "base_url": base_url, - "api_key": pool_token, - "source": "credential_pool", - "last_refresh": None, - "auth_mode": "chatgpt", - } + 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 @@ -4873,43 +4608,19 @@ def resolve_codex_runtime_credentials( clear_codex_pool_quota_cooldowns() pool_token = _pool_codex_access_token() if pool_token: - base_url = ( - os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") - or DEFAULT_CODEX_BASE_URL - ) - return { - "provider": "openai-codex", - "base_url": base_url, - "api_key": pool_token, - "source": "credential_pool", - "last_refresh": None, - "auth_mode": "chatgpt", - } + return _codex_runtime_result(pool_token, source="credential_pool", last_refresh=None) reset_at = pool_rate_limit.get("reset_at") - if isinstance(reset_at, (int, float)) and reset_at > time.time(): - remaining = int(reset_at - time.time()) - message = ( - f"Codex provider quota exhausted (429); retry after {remaining}s. " - "Credentials are still valid." - ) - else: - message = ( - "Codex provider quota exhausted (429). Credentials are still valid; " - "retry after the usage limit resets." - ) - raise AuthError( - message, - provider="openai-codex", - code=CODEX_RATE_LIMITED_CODE, - relogin_required=False, + 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 AuthError( + raise _codex_err( "No Codex credentials stored. Run `hermes auth` to authenticate.", - provider="openai-codex", - code="codex_auth_missing", - relogin_required=True, + "codex_auth_missing", relogin=True, ) tokens = dict(data["tokens"]) @@ -4934,20 +4645,10 @@ def resolve_codex_runtime_credentials( tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds) access_token = str(tokens.get("access_token", "") or "").strip() - base_url = ( - os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") - or DEFAULT_CODEX_BASE_URL + return _codex_runtime_result( + access_token, source="hermes-auth-store", last_refresh=data.get("last_refresh"), ) - return { - "provider": "openai-codex", - "base_url": base_url, - "api_key": access_token, - "source": "hermes-auth-store", - "last_refresh": data.get("last_refresh"), - "auth_mode": "chatgpt", - } - def _is_codex_rate_limit_shaped( code: Any, @@ -4979,18 +4680,13 @@ _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 (codex-rs backend-client, same - logic as ``agent.account_usage._codex_backend_urls``): base URLs - containing ``/backend-api`` use the ChatGPT ``/wham/usage`` path; - everything else uses ``/api/codex/usage``. Kept local so this low-level - auth module doesn't import the auxiliary account-usage module. + 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 = ( - os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") - or DEFAULT_CODEX_BASE_URL - ) + normalized = _codex_base_url() if normalized.endswith("/codex"): normalized = normalized[: -len("/codex")] prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex") @@ -5005,24 +4701,8 @@ def _probe_codex_quota_restored( ) -> Optional[bool]: """Ask the Codex usage endpoint whether this account's quota is usable again. - Hermes persists a Codex 429's ``reset_at`` locally and freezes the - credential until it elapses — but the upstream window can reopen EARLY - (the user redeems a banked rate-limit reset via the Codex CLI/ChatGPT UI, - upgrades their plan, or OpenAI resets the window). This probe detects - that: it GETs the same ``/usage`` endpoint the Codex CLI uses and checks - the reported windows. - - Returns: - * ``True`` — every reported rate-limit window is below 100% used; - the account can serve requests again and stale local cooldowns - should be lifted. - * ``False`` — a window is still fully used (or the probe itself 429'd); - keep the cooldown. - * ``None`` — indeterminate (no token, network error, unexpected - payload/status); keep the cooldown. - - Probes are throttled per access token (module-local cache) so the hot - selection path can fire this freely. + 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: @@ -5057,7 +4737,7 @@ def _probe_codex_quota_restored( if isinstance(claims.get("https://api.openai.com/auth"), dict) else None ) - if isinstance(account_id, str) and account_id.strip(): + 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) @@ -5085,26 +4765,20 @@ def _probe_codex_quota_restored( 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``. Only lifts - ``exhausted`` entries whose error metadata is 429/quota-shaped — DEAD - (terminal auth) entries and non-rate-limit failures are untouched. + 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). - - Returns the number of entries cleared. + 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() - pool = auth_store.get("credential_pool") - entries = pool.get("openai-codex") if isinstance(pool, dict) else None - if not isinstance(entries, list): + entries = _pool_entries(auth_store, "openai-codex") + if entries is None: return 0 for entry in entries: if not isinstance(entry, dict): @@ -5119,12 +4793,7 @@ def clear_codex_pool_quota_cooldowns(access_token: Optional[str] = None) -> int: entry.get("last_error_message"), ): continue - entry["last_status"] = None - entry["last_status_at"] = None - entry["last_error_code"] = None - entry["last_error_reason"] = None - entry["last_error_message"] = None - entry["last_error_reset_at"] = None + _clear_pool_entry_status(entry) cleared += 1 if cleared: _save_auth_store(auth_store) @@ -5162,34 +4831,23 @@ def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: try: with _auth_store_lock(): auth_store = _load_auth_store() - pool = auth_store.get("credential_pool") - if not isinstance(pool, dict): - return None - entries = pool.get("openai-codex") - if not isinstance(entries, list): + 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 isinstance(token, str) or not token.strip(): + if not _nonempty_str(token): continue if entry.get("last_status") != "exhausted": continue - code = entry.get("last_error_code") - reason = str(entry.get("last_error_reason") or "").lower() - message = str(entry.get("last_error_message") or "").lower() - is_rate_limited = ( - code == 429 - or "rate_limit" in reason - or "usage_limit" in reason - or "quota" in reason - or "rate limit" in message - or "usage limit" in message - or "quota" in message - ) - if not is_rate_limited: + 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: @@ -5208,37 +4866,36 @@ def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: 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. - Returns ``""`` when no usable entry is found (caller handles by raising - the original AuthError). + 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() - pool = auth_store.get("credential_pool") - if not isinstance(pool, dict): - return "" - entries = pool.get("openai-codex") - if not isinstance(entries, list): + 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 isinstance(token, str) or not token.strip(): + if not _nonempty_str(token): return False # Skip entries currently in an exhaustion cooldown window. reset_at = entry.get("last_error_reset_at") - if isinstance(reset_at, (int, float)) and reset_at > time.time(): - return False - return True + return not (isinstance(reset_at, (int, float)) and reset_at > time.time()) for entry in entries: if _entry_usable(entry): @@ -5300,46 +4957,34 @@ def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool: def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: - if _lock: - with _auth_store_lock(): - auth_store = _load_auth_store() - else: - auth_store = _load_auth_store() + 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 AuthError( + raise _xai_err( "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.", - provider="xai-oauth", - code="xai_auth_missing", - relogin_required=True, + "xai_auth_missing", relogin=True, ) tokens = state.get("tokens") if not isinstance(tokens, dict): - raise AuthError( + raise _xai_err( "xAI OAuth state is missing tokens. Re-authenticate with `hermes model`.", - provider="xai-oauth", - code="xai_auth_invalid_shape", - relogin_required=True, + "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 AuthError( + raise _xai_err( "xAI OAuth state is missing access_token. Re-authenticate with `hermes model`.", - provider="xai-oauth", - code="xai_auth_missing_access_token", - relogin_required=True, + "xai_auth_missing_access_token", relogin=True, ) if not refresh_token: - raise AuthError( + raise _xai_err( "xAI OAuth state is missing refresh_token. Re-authenticate with `hermes model`.", - provider="xai-oauth", - code="xai_auth_missing_refresh_token", - relogin_required=True, + "xai_auth_missing_refresh_token", relogin=True, ) return { "tokens": tokens, @@ -5349,31 +4994,16 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: } -def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool: - """True when this store has its OWN ``providers.xai-oauth`` block. - - Distinguishes a profile that genuinely shadows the root xAI grant from - one that only *reads* root via ``_load_provider_state``'s fallback. Only - the latter needs the refresh write-through below. - """ - providers = auth_store.get("providers") - return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict) - - 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. Otherwise root keeps a now-revoked refresh token - and every other profile reading the stale root grant dies with - ``invalid_grant`` once its access token expires. + 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. + 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: @@ -5414,14 +5044,13 @@ def _save_xai_oauth_tokens( ) -> 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. + 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 = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + 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 @@ -5487,13 +5116,10 @@ def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> b def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: """How far before JWT ``exp`` to proactively refresh xAI OAuth tokens. - SuperGrok sessions can still ship multi-hour access tokens, where the - gateway-oriented :data:`XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS` window - makes sense. Device-code logins often return ~15-minute JWTs; applying - the full hour-long skew to those forces a refresh on *every* credential - resolution (chat turn, Imagine tool call, ``hermes auth status``, …), - which burns single-use refresh tokens and races concurrent callers into - ``invalid_grant`` quarantine. + 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: @@ -5518,43 +5144,38 @@ def _xai_proactive_refresh_skew_seconds(access_token: str) -> int: 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 OIDC discovery response is a long-lived, low-frequency request whose - output is cached in ``~/.hermes/auth.json``. A single MITM during initial - login could substitute a malicious ``token_endpoint``; that URL would - then receive the refresh_token on every subsequent refresh — a permanent - credential leak from a one-time MITM. Validating scheme + host pins the - cached endpoint to the xAI auth origin (or a future ``*.x.ai`` subdomain - if xAI migrates) so the cache poisoning loses its persistence guarantee. - - RFC 8414 §2 requires the issuer to be ``https://`` and SHOULD-keeps the - token_endpoint on the same origin; we enforce both. ``x.ai`` is the - bare apex, so we accept either exact host match or any ``.x.ai`` suffix. + 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 AuthError( + raise _xai_err( f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}.", - provider="xai-oauth", - code="xai_discovery_invalid", + "xai_discovery_invalid", ) host = (parsed.hostname or "").lower() if not host: - raise AuthError( + raise _xai_err( f"xAI OIDC discovery {field} is missing a hostname: {url!r}.", - provider="xai-oauth", - code="xai_discovery_invalid", + "xai_discovery_invalid", ) - if host != "x.ai" and not host.endswith(".x.ai"): - raise AuthError( + 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.", - provider="xai-oauth", - code="xai_discovery_invalid", + "xai_discovery_invalid", ) return url @@ -5562,21 +5183,12 @@ def _xai_validate_oauth_endpoint(url: str, *, field: str) -> str: def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str: """Refuse a non-xAI base_url for the OAuth-authenticated inference path. - The xAI Grok OAuth bearer is a high-value, long-lived credential tied to - the user's SuperGrok subscription. ``XAI_BASE_URL`` / ``HERMES_XAI_BASE_URL`` - let users repoint the inference endpoint (handy for staging or a local - proxy), but the env override is also a credential-leak vector: a tampered - ``.env`` or hostile shell init that sets - ``XAI_BASE_URL=https://attacker.example/v1`` would ship the OAuth access - token to a third party on every request, silently. + 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. - 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. + ``value`` is the already-stripped, trailing-slash-trimmed candidate from env. Empty input + returns ``fallback`` unchanged. """ candidate = (value or "").strip().rstrip("/") if not candidate: @@ -5603,7 +5215,7 @@ def _xai_validate_inference_base_url(value: str, *, fallback: str) -> str: candidate, fallback, ) return fallback - if host != "x.ai" and not host.endswith(".x.ai"): + 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 " @@ -5623,38 +5235,30 @@ def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: timeout=timeout_seconds, ) except Exception as exc: - raise AuthError( - f"xAI OIDC discovery failed: {exc}", - provider="xai-oauth", - code="xai_discovery_failed", - ) from exc + raise _xai_err(f"xAI OIDC discovery failed: {exc}", "xai_discovery_failed") from exc if response.status_code != 200: - raise AuthError( + raise _xai_err( f"xAI OIDC discovery returned status {response.status_code}.", - provider="xai-oauth", - code="xai_discovery_failed", + "xai_discovery_failed", ) try: payload = response.json() except Exception as exc: - raise AuthError( + raise _xai_err( f"xAI OIDC discovery returned invalid JSON: {exc}", - provider="xai-oauth", - code="xai_discovery_invalid_json", + "xai_discovery_invalid_json", ) from exc if not isinstance(payload, dict): - raise AuthError( + raise _xai_err( "xAI OIDC discovery response was not a JSON object.", - provider="xai-oauth", - code="xai_discovery_incomplete", + "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 AuthError( + raise _xai_err( "xAI OIDC discovery response was missing required endpoints.", - provider="xai-oauth", - code="xai_discovery_incomplete", + "xai_discovery_incomplete", ) _xai_validate_oauth_endpoint(authorization_endpoint, field="authorization_endpoint") _xai_validate_oauth_endpoint(token_endpoint, field="token_endpoint") @@ -5664,6 +5268,17 @@ def _xai_oauth_discovery(timeout_seconds: float = 15.0) -> Dict[str, str]: } +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, @@ -5672,12 +5287,10 @@ def refresh_xai_oauth_pure( timeout_seconds: float = 20.0, ) -> Dict[str, Any]: del access_token - if not isinstance(refresh_token, str) or not refresh_token.strip(): - raise AuthError( + if not _nonempty_str(refresh_token): + raise _xai_err( "xAI OAuth is missing refresh_token. Re-authenticate with `hermes model`.", - provider="xai-oauth", - code="xai_auth_missing_refresh_token", - relogin_required=True, + "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 @@ -5707,7 +5320,7 @@ def refresh_xai_oauth_pure( # re-authenticate hint, and point users at the ``XAI_API_KEY`` # fallback. See #26847. if response.status_code == 403: - raise AuthError( + 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" @@ -5717,49 +5330,32 @@ def refresh_xai_oauth_pure( " 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.", - provider="xai-oauth", - code="xai_oauth_tier_denied", - relogin_required=False, + "xai_oauth_tier_denied", relogin=False, ) - raise AuthError( + raise _xai_err( "xAI token refresh failed." + (f" Response: {detail}" if detail else ""), - provider="xai-oauth", - code="xai_refresh_failed", - relogin_required=(response.status_code in {400, 401}), + "xai_refresh_failed", relogin=response.status_code in {400, 401}, ) - try: - payload = response.json() - except Exception as exc: - raise AuthError( - f"xAI token refresh returned invalid JSON: {exc}", - provider="xai-oauth", - code="xai_refresh_invalid_json", - ) from exc - if not isinstance(payload, dict): - raise AuthError( + 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.", - provider="xai-oauth", - code="xai_refresh_invalid_response", - relogin_required=True, - ) - refreshed_access = str(payload.get("access_token", "") or "").strip() - if not refreshed_access: - raise AuthError( + "xai_refresh_invalid_response", + ), + missing_access=( "xAI token refresh response was missing access_token.", - provider="xai-oauth", - code="xai_refresh_missing_access_token", - relogin_required=True, - ) - updated = { - "access_token": refreshed_access, - "refresh_token": str(payload.get("refresh_token") or refresh_token).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", - "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "xai_refresh_missing_access_token", + ), + ) + return { + **_xai_tokens_from_payload(payload, refreshed_access, refresh_token), + "last_refresh": _utc_now_z(), } - return updated def _refresh_xai_oauth_tokens( @@ -5805,44 +5401,69 @@ def _refresh_xai_oauth_tokens( 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]: - data = _read_xai_oauth_tokens() - tokens = dict(data["tokens"]) - access_token = str(tokens.get("access_token", "") or "").strip() - refresh_timeout_seconds = env_float("HERMES_XAI_REFRESH_TIMEOUT_SECONDS", 20) - discovery = dict(data.get("discovery") or {}) - token_endpoint = str(discovery.get("token_endpoint", "") or "").strip() - redirect_uri = str(data.get("redirect_uri", "") or "").strip() + 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 - 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) + 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 = 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) + 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"] @@ -5856,37 +5477,10 @@ def resolve_xai_oauth_runtime_credentials( access_token = str(tokens.get("access_token", "") or "").strip() except AuthError as exc: if _is_terminal_xai_oauth_refresh_error(exc): - # Terminal failure (HTTP 400/401/403 — invalid_grant, token revoked). - # Clear dead tokens from auth.json so subsequent sessions fail fast - # without a network retry. Mirrors credential_pool.py quarantine. - 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"] = { - "provider": "xai-oauth", - "code": exc.code or "xai_refresh_failed", - "message": str(exc), - "reason": "runtime_refresh_failure", - "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } - _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, - ) + _quarantine_xai_oauth_tokens(exc) raise - base_url = _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, - ) + base_url = _xai_oauth_inference_base_url() return { "provider": "xai-oauth", "base_url": base_url, @@ -5907,11 +5501,10 @@ def resolve_xai_oauth_runtime_credentials( 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. + 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: @@ -5991,10 +5584,8 @@ def _request_device_code( def _nous_device_auth_timeout_message(portal_base_url: str) -> str: """Actionable timeout text for Nous device-code login failures. - A bare "Timed out waiting for device authorization" gives the user - nothing to act on. The most common cause is Portal sign-in failing in - the opened browser tab (including the server-side CAPTCHA loop from - #20605), so point at the Portal login page and the retry command. + 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 ( @@ -6007,6 +5598,80 @@ def _nous_device_auth_timeout_message(portal_base_url: str) -> str: ) +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, @@ -6015,48 +5680,35 @@ def _poll_for_token( expires_in: int, poll_interval: int, ) -> Dict[str, Any]: - """Poll the token endpoint until the user approves or the code expires.""" - deadline = time.monotonic() + max(1, expires_in) - current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) + """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") - while time.monotonic() < deadline: - response = client.post( + 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": "urn:ietf:params:oauth:grant-type:device_code", + "grant_type": DEVICE_CODE_GRANT_TYPE, "client_id": client_id, "device_code": device_code, }, - ) - - if response.status_code == 200: - payload = response.json() - if "access_token" not in payload: - raise ValueError("Token response did not include access_token") - return payload - - try: - error_payload = response.json() - except Exception: - response.raise_for_status() - raise RuntimeError("Token endpoint returned a non-JSON error response") - - error_code = error_payload.get("error", "") - 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 - - description = error_payload.get("error_description") or "Unknown authentication error" - raise RuntimeError(f"{error_code}: {description}") - - # 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). - raise TimeoutError(_nous_device_auth_timeout_message(portal_base_url)) + ), + 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)), + ) # ============================================================================= @@ -6089,15 +5741,9 @@ _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 to a tmp - path without touching the real user's home. Defaults to - ``/shared/``, where ```` is what - :func:`hermes_constants.get_default_hermes_root` returns — so - Linux/macOS classic installs land at ``~/.hermes/shared/``, native - Windows installs at ``%LOCALAPPDATA%\\hermes\\shared\\``, and - Docker / custom ``HERMES_HOME`` deployments at - ``/shared/``. Sits outside any named profile so all - profiles under the same root share the store. + Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it. Defaults to + ``/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: @@ -6135,13 +5781,8 @@ def _nous_shared_store_path() -> Path: 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. The one exception is - ``_try_import_shared_nous_state``, which holds this lock alone for - the entire refresh cycle so concurrent imports on sibling profiles - can't race on the single-use shared refresh token; that helper must - NOT be called with ``_auth_store_lock`` already held. + 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") @@ -6159,6 +5800,20 @@ def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): 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() @@ -6166,7 +5821,7 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: return False shared_refresh = shared.get("refresh_token") - if not isinstance(shared_refresh, str) or not shared_refresh.strip(): + if not _nonempty_str(shared_refresh): return False local_refresh = state.get("refresh_token") @@ -6177,82 +5832,51 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: if not refresh_changed and not fresher_access: return False - for key in ( - "access_token", - "refresh_token", - "token_type", - "scope", - "client_id", - "portal_base_url", - "inference_base_url", - "obtained_at", - "expires_at", - ): + 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. - - We deliberately omit the runtime ``agent_key`` compatibility field; - the OAuth tokens are the cross-profile source of truth. + 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") - if not (isinstance(refresh_token, str) and refresh_token.strip()): - # No refresh_token = nothing worth sharing across profiles - return - if not (isinstance(access_token, str) and access_token.strip()): + # No refresh_token = nothing worth sharing across profiles + if not (_nonempty_str(refresh_token) and _nonempty_str(access_token)): return shared = { "_schema": 1, - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": state.get("token_type") or "Bearer", - "scope": state.get("scope") or DEFAULT_NOUS_SCOPE, - "client_id": state.get("client_id") or DEFAULT_NOUS_CLIENT_ID, - "portal_base_url": state.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, - "inference_base_url": state.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, - "obtained_at": state.get("obtained_at"), - "expires_at": state.get("expires_at"), + **_nous_shared_shape(state), "updated_at": datetime.now(timezone.utc).isoformat(), } try: with _nous_shared_store_lock(): path = _nous_shared_store_path() - path.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(path) - tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") - # Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU - # window where write_text() + post-write chmod briefly exposed Nous - # refresh_token at process umask. See #19673, #21148. - fd = os.open( - str(tmp), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - stat.S_IRUSR | stat.S_IWUSR, + _write_private_file_atomic( + path, json.dumps(shared, indent=2, sort_keys=True), replace=os.replace, ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(json.dumps(shared, indent=2, sort_keys=True)) - fh.flush() - os.fsync(fh.fileno()) - os.replace(tmp, path) - finally: - try: - if tmp.exists(): - tmp.unlink() - except OSError: - pass _oauth_trace( "nous_shared_store_written", path=str(path), @@ -6265,9 +5889,8 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: 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 should treat ``None`` as "no shared - credentials available — fall through to device-code". + 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() @@ -6283,11 +5906,7 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]: return None if not isinstance(payload, dict): return None - refresh_token = payload.get("refresh_token") - access_token = payload.get("access_token") - if not (isinstance(refresh_token, str) and refresh_token.strip()): - return None - if not (isinstance(access_token, str) and access_token.strip()): + if not (_nonempty_str(payload.get("refresh_token")) and _nonempty_str(payload.get("access_token"))): return None return payload @@ -6306,54 +5925,39 @@ def _clear_shared_nous_state(reason: str) -> None: logger.debug("Failed to clear shared Nous auth store: %s", exc) -def _is_terminal_nous_refresh_error(exc: Exception) -> bool: - """True when retrying the same Nous refresh token cannot succeed.""" +# 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 == "nous" - and exc.code in {"invalid_grant", "invalid_token", "refresh_token_reused"} + 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: - """True when retrying the same xAI OAuth refresh token cannot succeed. - - ``xai_refresh_failed`` covers HTTP 400/401/403 from the token endpoint - (invalid_grant, token revoked, refresh_token_reused). - ``xai_auth_missing_refresh_token`` means the pool entry has no refresh - token at all — retrying will never work. - Both carry ``relogin_required=True``; transient failures (429, 5xx) do not. - """ - return ( - isinstance(exc, AuthError) - and exc.provider == "xai-oauth" - and exc.code in {"xai_refresh_failed", "xai_auth_missing_refresh_token"} - and bool(exc.relogin_required) - ) + return _is_terminal_refresh_error(exc, "xai-oauth") def _is_terminal_codex_oauth_refresh_error(exc: Exception) -> bool: - """True when retrying the same Codex OAuth refresh token cannot succeed. - - ``codex_refresh_failed`` covers HTTP 400/401/403 from the token endpoint - (invalid_grant, token revoked, refresh_token_reused). - ``codex_auth_missing_refresh_token`` means the pool entry has no refresh - token at all — retrying will never work. - Both carry ``relogin_required=True``; transient failures (429, 5xx) do not. - """ - return ( - isinstance(exc, AuthError) - and exc.provider == "openai-codex" - and exc.code in { - "codex_refresh_failed", - "codex_auth_missing_refresh_token", - "invalid_grant", - "invalid_token", - "refresh_token_reused", - } - and bool(exc.relogin_required) - ) + return _is_terminal_refresh_error(exc, "openai-codex") def _quarantine_nous_oauth_state( @@ -6415,28 +6019,9 @@ def _quarantine_nous_oauth_state( json.dumps(forensic, sort_keys=True, ensure_ascii=False), ) - for key in ( - "access_token", - "refresh_token", - "expires_at", - "expires_in", - "obtained_at", - "agent_key", - "agent_key_id", - "agent_key_expires_at", - "agent_key_expires_in", - "agent_key_reused", - "agent_key_obtained_at", - ): + for key in (*_FLAT_OAUTH_TOKEN_KEYS, *_NOUS_EMPTY_AGENT_KEY_FIELDS): state.pop(key, None) - state["last_auth_error"] = { - "provider": "nous", - "code": error.code, - "message": str(error), - "reason": reason, - "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } + state["last_auth_error"] = _last_auth_error_marker("nous", error, reason=reason) _clear_shared_nous_state(reason) invalidate_nous_auth_status_cache() @@ -6448,12 +6033,10 @@ def _quarantine_nous_pool_entries( reason: str, ) -> bool: """Remove singleton-seeded Nous pool entries that contain dead OAuth state.""" - pool = auth_store.get("credential_pool") - if not isinstance(pool, dict): - return False - entries = pool.get("nous") - if not isinstance(entries, list): + entries = _pool_entries(auth_store, "nous") + if entries is None: return False + pool = auth_store["credential_pool"] retained = [] removed = False @@ -6480,15 +6063,10 @@ def _try_import_shared_nous_state( ) -> Optional[Dict[str, Any]]: """Attempt to rehydrate Nous OAuth state from the shared store. - Reads the shared file (if present), runs a forced refresh using the - stored refresh_token to produce a fresh inference JWT scoped to this - profile, and returns the full auth_state dict ready - for ``persist_nous_credentials()``. - - Returns ``None`` when no shared state is available or the rehydrate - fails for any reason (expired refresh_token, portal unreachable, - etc.) — caller should then fall through to the normal device-code - flow. + 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)): @@ -6500,15 +6078,7 @@ def _try_import_shared_nous_state( # field it needs. force_refresh=True gets us a fresh access_token # for this profile. state: Dict[str, Any] = { - "access_token": shared.get("access_token"), - "refresh_token": shared.get("refresh_token"), - "client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID, - "portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, - "inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, - "token_type": shared.get("token_type") or "Bearer", - "scope": shared.get("scope") or DEFAULT_NOUS_SCOPE, - "obtained_at": shared.get("obtained_at"), - "expires_at": shared.get("expires_at"), + **_nous_shared_shape(shared), "agent_key": None, "agent_key_expires_at": None, "tls": {"insecure": False, "ca_bundle": None}, @@ -6564,15 +6134,13 @@ def _refresh_access_token( if response.status_code == 200: payload = response.json() if "access_token" not in payload: - raise AuthError("Refresh response missing access_token", - provider="nous", code="invalid_token", relogin_required=True) + raise _nous_err("Refresh response missing access_token", "invalid_token", relogin=True) return payload try: error_payload = response.json() except Exception as exc: - raise AuthError("Refresh token exchange failed", - provider="nous", relogin_required=True) from 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") @@ -6600,7 +6168,73 @@ def _refresh_access_token( ) relogin = True - raise AuthError(description, provider="nous", code=code, relogin_required=relogin) + 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( @@ -6625,7 +6259,7 @@ def fetch_nous_models( 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 AuthError(description, provider="nous", code="models_fetch_failed") + raise _nous_err(description, "models_fetch_failed") payload = response.json() data = payload.get("data") @@ -6637,7 +6271,7 @@ def fetch_nous_models( if not isinstance(item, dict): continue model_id = item.get("id") - if isinstance(model_id, str) and model_id.strip(): + 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(): @@ -6662,7 +6296,7 @@ def fetch_nous_models( def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool: key = state.get("agent_key") - if not isinstance(key, str) or not key.strip(): + if not _nonempty_str(key): return False return _nous_invoke_jwt_is_usable( key, @@ -6709,11 +6343,7 @@ def resolve_nous_access_token( ): if not state: - raise AuthError( - "Hermes is not logged into Nous Portal.", - provider="nous", - relogin_required=True, - ) + 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 @@ -6747,10 +6377,9 @@ def resolve_nous_access_token( access_token = state.get("access_token") refresh_token = state.get("refresh_token") if not isinstance(access_token, str) or not access_token: - raise AuthError( + raise _nous_err( "No access token found for Nous Portal login.", - provider="nous", - relogin_required=True, + relogin=True, ) if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): @@ -6768,10 +6397,9 @@ def resolve_nous_access_token( return access_token if not isinstance(refresh_token, str) or not refresh_token: - raise AuthError( + raise _nous_err( "Session expired and no refresh token is available.", - provider="nous", - relogin_required=True, + relogin=True, ) timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) @@ -6780,46 +6408,23 @@ def resolve_nous_access_token( headers={"Accept": "application/json"}, verify=verify, ) as client: - try: - refreshed = _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="managed_access_token_refresh_failure", - ) - _quarantine_nous_pool_entries( - auth_store, - exc, - reason="managed_access_token_refresh_failure", - ) - _save_provider_state_to_source(auth_store, "nous", state, state_source_path) - raise + 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 + ), + ) - 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") - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, - tz=timezone.utc, - ).isoformat() + _apply_nous_refreshed_tokens(state, refreshed, refresh_token) state["portal_base_url"] = portal_base_url state["client_id"] = client_id - state["tls"] = { - "insecure": verify is False, - "ca_bundle": verify if isinstance(verify, str) else None, - } + 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"] @@ -6850,9 +6455,9 @@ def refresh_nous_oauth_pure( ) -> 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. + ``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, @@ -6884,18 +6489,15 @@ def refresh_nous_oauth_pure( 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 AuthError( + 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", - provider="nous", - code=current_invoke_jwt_status, - relogin_required=True, + current_invoke_jwt_status, relogin=True, ) - raise AuthError( + raise _nous_err( "No refresh token is available for Nous Portal.", - provider="nous", - relogin_required=True, + relogin=True, ) refreshed = _refresh_access_token( client=client, @@ -6903,26 +6505,10 @@ def refresh_nous_oauth_pure( client_id=state["client_id"], refresh_token=refresh_token_value, ) - 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_value - state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" - state["scope"] = refreshed.get("scope") or state.get("scope") - # Heal a poisoned stored value: 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. Without this reset, an auth.json that - # was poisoned before the allowlist existed keeps re-validating to - # None on every refresh and silently re-uses the dead endpoint — - # the "falling back to default" warning never actually takes effect. - refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) - state["inference_base_url"] = refreshed_url or DEFAULT_NOUS_INFERENCE_URL - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, tz=timezone.utc - ).isoformat() + _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") @@ -6967,34 +6553,12 @@ def persist_nous_credentials( label: Optional[str] = None, ): """Persist Nous OAuth credentials as the singleton provider state - and ensure the credential pool is in sync. - Nous credentials are read at runtime from two independent locations: - - - ``providers.nous``: singleton state read by - ``resolve_nous_runtime_credentials()`` during 401 recovery and by - ``_seed_from_singletons()`` during pool load. - - ``credential_pool.nous``: used by the runtime ``pool.select()`` path. - - Historically ``hermes auth add nous`` wrote a ``manual:device_code`` pool - entry only, skipping ``providers.nous``. When the runtime credential - expired, the recovery path read the empty singleton state and raised - ``AuthError`` silently (``logger.debug`` at INFO level). - - This helper writes ``providers.nous`` then calls ``load_pool("nous")`` so - ``_seed_from_singletons`` materialises the canonical ``device_code`` pool - entry from the singleton. Re-running login upserts the same entry in - place; the pool never accumulates duplicate device_code rows. - - ``label`` is an optional user-chosen display name (from - ``hermes auth add nous --label ``). It gets embedded in the - singleton state so that ``_seed_from_singletons`` uses it as the pool - entry's label on every subsequent ``load_pool("nous")`` instead of the - auto-derived token fingerprint. When ``None``, the auto-derived label - via ``label_from_token`` is used (unchanged default behaviour). - - Returns the upserted :class:`PooledCredential` entry (or ``None`` if - seeding somehow produced no match — shouldn't happen). + 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 @@ -7002,10 +6566,7 @@ def persist_nous_credentials( if label and str(label).strip(): state["label"] = str(label).strip() - with _auth_store_lock(): - auth_store = _load_auth_store() - _save_provider_state(auth_store, "nous", state) - _save_auth_store(auth_store) + _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- @@ -7030,6 +6591,119 @@ def _sync_nous_pool_from_auth_store() -> None: 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, @@ -7038,8 +6712,7 @@ def resolve_nous_runtime_credentials( force_refresh: bool = False, stale_access_token: Optional[str] = None, ) -> Dict[str, Any]: - """ - Resolve Nous inference credentials for runtime use. + """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. @@ -7047,11 +6720,9 @@ def resolve_nous_runtime_credentials( ``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. Without this, N concurrent - processes hitting the same hourly expiry issue N refreshes, and each - rotation invalidates the token a sibling just adopted (Sep 2026: 120 - subagents, 81 refreshes, ~540 401s in eight minutes). + 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] @@ -7062,8 +6733,7 @@ def resolve_nous_runtime_credentials( ): if not state: - raise AuthError("Hermes is not logged into Nous Portal.", - provider="nous", relogin_required=True) + raise _nous_err("Hermes is not logged into Nous Portal.", relogin=True) def _already_rotated_by_peer(token: Any) -> bool: return bool( @@ -7079,114 +6749,15 @@ def resolve_nous_runtime_credentials( ) is None ) - persisted_state = dict(state) - state_persisted = False - - def _resolve_effective_routing_metadata() -> tuple[str, str, str, str]: - """Resolve every routing value that shared OAuth state can replace.""" - 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 - - # Re-validate persisted network-provenance on every shared merge. - # The env override is runtime-only and must never be persisted. - stored_inference_url = ( - _validate_nous_inference_url_from_network( - _optional_base_url(state.get("inference_base_url")) - ) - or DEFAULT_NOUS_INFERENCE_URL - ) - effective_inference_url = ( - _nous_inference_env_override() or stored_inference_url - ) - effective_client_id = str( - state.get("client_id") or DEFAULT_NOUS_CLIENT_ID - ) - return ( - portal_url, - stored_inference_url, - effective_inference_url, - effective_client_id, - ) + 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, - ) = _resolve_effective_routing_metadata() - - def _persist_state(reason: str) -> None: - nonlocal persisted_state, state_persisted - # Skip writes where only derived TTL countdowns changed; this keeps - # the mtime-keyed Nous auth-status cache warm during read paths. - if ( - _nous_effective_provider_state(state) - == _nous_effective_provider_state(persisted_state) - ): - _oauth_trace( - "nous_state_persist_skipped", - sequence_id=sequence_id, - reason=reason, - ) - return - try: - _save_provider_state_to_source(auth_store, "nous", state, state_source_path) - except Exception as exc: - _oauth_trace( - "nous_state_persist_failed", - sequence_id=sequence_id, - reason=reason, - error_type=type(exc).__name__, - ) - raise - _oauth_trace( - "nous_state_persisted", - sequence_id=sequence_id, - reason=reason, - refresh_token_fp=_token_fingerprint(state.get("refresh_token")), - access_token_fp=_token_fingerprint(state.get("access_token")), - ) - persisted_state = dict(state) - state_persisted = True - # Mirror post-refresh state to the shared store so sibling - # profiles don't hold stale refresh_tokens after rotation. - # Best-effort — any failure is logged and swallowed inside - # _write_shared_nous_state. - _write_shared_nous_state(state) + ) = _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) @@ -7212,12 +6783,14 @@ def resolve_nous_runtime_credentials( stored_inference_base_url, inference_base_url, client_id, - ) = _resolve_effective_routing_metadata() + ) = _nous_effective_routing(state) _persist_state("runtime_shared_merge_missing_access_token") if not isinstance(access_token, str) or not access_token: - raise AuthError("No access token found for Nous Portal login.", - provider="nous", relogin_required=True) + raise _nous_err( + "No access token found for Nous Portal login.", + relogin=True, + ) invoke_jwt_status = _nous_invoke_jwt_status( access_token, @@ -7244,7 +6817,7 @@ def resolve_nous_runtime_credentials( stored_inference_base_url, inference_base_url, client_id, - ) = _resolve_effective_routing_metadata() + ) = _nous_effective_routing(state) invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), @@ -7262,13 +6835,11 @@ def resolve_nous_runtime_credentials( 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 AuthError( + 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", - provider="nous", - code=reason, - relogin_required=True, + reason, relogin=True, ) refresh_reason = "force_refresh" if force_refresh else (invoke_jwt_status or "access_unusable") @@ -7278,54 +6849,30 @@ def resolve_nous_runtime_credentials( reason=refresh_reason, refresh_token_fp=_token_fingerprint(refresh_token), ) - try: - refreshed = _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="runtime_access_refresh_failure", - ) - _quarantine_nous_pool_entries( - auth_store, - exc, - reason="runtime_access_refresh_failure", - ) - _persist_state("terminal_runtime_access_refresh_failure") - raise - now = datetime.now(timezone.utc) - access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + 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 - 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") - # Heal a poisoned stored value (see refresh_nous_oauth_pure): - # reject → reset to production default, don't keep a stale - # staging host that re-validates to None every refresh. - # This (validated, network-provenance) value is what gets - # persisted to auth.json below. The NOUS_INFERENCE_BASE_URL - # env override is layered on for the client/return value - # only (see below) — it is never persisted. - refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) - stored_inference_base_url = refreshed_url or DEFAULT_NOUS_INFERENCE_URL + # 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 ) - # Persist network-derived routing with rotated tokens so - # a later JWT validation failure cannot leave the profile - # and shared stores on stale metadata. Never persist the - # operator-only env overlay. - state["inference_base_url"] = stored_inference_base_url - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, tz=timezone.utc - ).isoformat() + _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( @@ -7355,20 +6902,16 @@ def resolve_nous_runtime_credentials( state["portal_base_url"] = portal_base_url state["inference_base_url"] = stored_inference_base_url state["client_id"] = client_id - state["tls"] = { - "insecure": verify is False, - "ca_bundle": verify if isinstance(verify, str) else None, - } + state["tls"] = _tls_state_from_verify(verify) _persist_state("resolve_nous_runtime_credentials_final") - if state_persisted: + 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 AuthError("Failed to resolve a Nous inference API key", - provider="nous", code="server_error") + 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) @@ -7414,8 +6957,8 @@ def _empty_nous_auth_status() -> Dict[str, Any]: 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. + 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 @@ -7505,10 +7048,9 @@ def _auth_file_cache_key() -> Tuple[str, Optional[float]]: def invalidate_nous_auth_status_cache() -> None: """Clear the get_nous_auth_status() process-level memo. - Call this from any code path that mutates Nous auth state without going - through resolve_nous_runtime_credentials() (e.g. tests). Login/logout - flows touch auth.json, so the mtime check below invalidates them - automatically — explicit invalidation is the belt-and-braces option. + 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 @@ -7517,16 +7059,13 @@ def invalidate_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. If provider state is absent, fall back to the credential - pool for the just-logged-in / not-yet-promoted case. + 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 + 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 @@ -7547,24 +7086,30 @@ def get_nous_auth_status() -> Dict[str, Any]: 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 = { - "logged_in": bool(state.get("access_token")), - "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": state.get("access_token"), - "inference_credential_present": bool( - state.get("access_token") or state.get("agent_key") - ), - "credential_source": "auth_store", - "source": "auth_store", - } + 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 @@ -7602,20 +7147,15 @@ def _compute_nous_auth_status() -> Dict[str, Any]: 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. + 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. - Use this from status panels, doctor checks, and polled dashboard - endpoints. Explicit auth actions (login flows, portal operations that - need a live credential) should keep using ``get_nous_auth_status()``. - - ``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. + ``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") @@ -7641,20 +7181,7 @@ def get_nous_auth_status_local() -> Dict[str, Any]: bool(state.get("refresh_token")) and not terminal ) - status: Dict[str, Any] = { - "logged_in": logged_in, - "portal_base_url": state.get("portal_base_url"), - "inference_base_url": state.get("inference_base_url"), - "access_token": access_token, - "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")), - "inference_credential_present": bool( - access_token or state.get("agent_key") - ), - "credential_source": "auth_store", - "source": "auth_store_local", - } + 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") @@ -7674,25 +7201,13 @@ NOUS_SESSION_UNKNOWN = "unknown" def get_nous_session_validity() -> str: """Classify the Nous bootstrap session for the dashboard /api/status probe. - Returns one of: - - ``"valid"`` — a usable Nous credential is present (login healthy). - - ``"terminal"`` — the Nous session has taken a terminal auth failure - (invalid_grant / quarantined / relogin required). This is the sole - signal NAS acts on to re-mint a hosted-agent bootstrap session. - - ``"unknown"`` — indeterminate (no Nous provider state, or a transient/ - non-terminal error). Never triggers a re-mint. + 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. - 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). We key "terminal" on the auth layer's own terminal signal - (`relogin_required`) plus a persisted quarantine marker, never on a bare - "not logged in". + 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` @@ -7708,12 +7223,15 @@ def get_nous_session_validity() -> str: return NOUS_SESSION_UNKNOWN last_err = state.get("last_auth_error") - if isinstance(last_err, dict) and last_err.get("relogin_required"): - # 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 not (state.get("access_token") or state.get("refresh_token")): - return NOUS_SESSION_TERMINAL + # 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"), @@ -7728,17 +7246,24 @@ def get_nous_session_validity() -> str: return NOUS_SESSION_UNKNOWN -def get_codex_auth_status() -> Dict[str, Any]: - """Status snapshot for Codex auth. - - Checks the credential pool first (where `hermes auth` stores credentials), - then falls back to the legacy provider state. +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*. """ - # Check credential pool first — this is where `hermes auth` and - # `hermes model` store device_code tokens. try: from agent.credential_pool import load_pool - pool = load_pool("openai-codex") + + pool = load_pool(provider_id) if pool and pool.has_credentials(): entry = pool.select() if entry is not None: @@ -7746,37 +7271,24 @@ def get_codex_auth_status() -> Dict[str, Any]: getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") ) - if api_key and not _codex_access_token_is_expiring(api_key, 0): + 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": "chatgpt", + "auth_mode": auth_mode, "source": f"pool:{getattr(entry, 'label', 'unknown')}", "api_key": api_key, } - rate_limit = _codex_pool_rate_limit_status() - if rate_limit: - 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"), - } + if on_pool_miss is not None: + degraded = on_pool_miss() + if degraded: + return degraded except Exception: pass - # Fall back to legacy provider state try: - creds = resolve_codex_runtime_credentials() + creds = resolve() return { "logged_in": True, "auth_store": str(_auth_file_path()), @@ -7793,49 +7305,50 @@ def get_codex_auth_status() -> Dict[str, Any]: } +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]: - try: - from agent.credential_pool import load_pool + 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, + ) - pool = load_pool("xai-oauth") - 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 _xai_access_token_is_expiring(api_key, 0): - return { - "logged_in": True, - "auth_store": str(_auth_file_path()), - "last_refresh": getattr(entry, "last_refresh", None), - # 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", - "source": f"pool:{getattr(entry, 'label', 'unknown')}", - "api_key": api_key, - } - except Exception: - pass - try: - creds = resolve_xai_oauth_runtime_credentials() - 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 _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]: @@ -7863,13 +7376,8 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: "logged_in": True, } - api_key = "" - key_source = "" api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) - - env_url = "" - if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip() + 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) @@ -7956,6 +7464,41 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st 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. @@ -7968,86 +7511,73 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: if not pconfig or pconfig.auth_type != "external_process": return {"configured": False} - command = ( - os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() - or os.getenv("COPILOT_CLI_PATH", "").strip() - or "copilot" - ) - raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() - args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"] - 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 - - resolved_command = shutil.which(command) if command else None + 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": bool(resolved_command or base_url.startswith("acp+tcp://")), + "configured": available, "provider": provider_id, "name": pconfig.name, "command": command, "args": args, "resolved_command": resolved_command, "base_url": base_url, - "logged_in": bool(resolved_command or base_url.startswith("acp+tcp://")), + "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} - if target == "spotify": - return get_spotify_auth_status() - if target == "nous": - return get_nous_auth_status() - if target == "openai-codex": - return get_codex_auth_status() - if target == "xai-oauth": - return get_xai_oauth_auth_status() - if target == "qwen-oauth": - return get_qwen_auth_status() - if target == "minimax-oauth": - return get_minimax_oauth_auth_status() - if target == "azure-foundry": - return _get_azure_foundry_auth_status() + # 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) - # External-process providers (copilot-acp today; kiro/devin/junie-style 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. - if pconfig and pconfig.auth_type == "external_process": - return get_external_process_provider_status(target) - # API-key providers - if pconfig and pconfig.auth_type == "api_key": - return get_api_key_provider_status(target) - # AWS SDK providers (Bedrock) — check via boto3 credential chain - if pconfig and pconfig.auth_type == "aws_sdk": - 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"} + 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. - ``logged_in`` is structural, matching other non-OAuth provider status - checks: + * ``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. - * ``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. + 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: @@ -8112,10 +7642,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: - """Resolve API key and base URL for an API-key provider. - - Returns dict with: provider, api_key, base_url, source. - """ + """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( @@ -8124,8 +7651,6 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: code="invalid_provider", ) - api_key = "" - key_source = "" api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) # No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client @@ -8135,9 +7660,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: api_key = LMSTUDIO_NOAUTH_PLACEHOLDER key_source = key_source or "default" - env_url = "" - if pconfig.base_url_env_var: - env_url = os.getenv(pconfig.base_url_env_var, "").strip() + 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) @@ -8177,7 +7700,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: # 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 (isinstance(base_url, str) and base_url.strip()): + 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): @@ -8202,40 +7725,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, code="invalid_provider", ) - 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 - - # 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. - profile = None - try: - from providers import get_provider_profile as _get_provider_profile - - profile = _get_provider_profile(provider_id) - except Exception: - profile = None - - command_env_vars = tuple(getattr(profile, "process_command_env_vars", ()) or ()) - default_command = str(getattr(profile, "process_command", "") or "") - default_args = list(getattr(profile, "process_args", ()) or []) - args_env_var = str(getattr(profile, "process_args_env_var", "") or "") - - command = "" - for _var in command_env_vars: - command = os.getenv(_var, "").strip() - if command: - break - if not command: - command = default_command - - raw_args = os.getenv(args_env_var, "").strip() if args_env_var else "" - args = shlex.split(raw_args) if raw_args else list(default_args) - - resolved_command = shutil.which(command) if command else None + 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 "" @@ -8270,12 +7760,10 @@ def _update_config_for_provider( ) -> 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. ``anthropic/claude-opus-4.6`` sent to - MiniMax's API). + 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(): @@ -8293,7 +7781,7 @@ def _update_config_for_provider( current_model = config.get("model") if isinstance(current_model, dict): model_cfg = dict(current_model) - elif isinstance(current_model, str) and current_model.strip(): + elif _nonempty_str(current_model): model_cfg = {"default": current_model.strip()} else: model_cfg = {} @@ -8362,11 +7850,9 @@ def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool: def _logout_default_provider_from_config() -> Optional[str]: """Fallback logout target when auth.json has no active provider. - `hermes logout` historically keyed off auth.json.active_provider only. - 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. + 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"}: @@ -8404,9 +7890,8 @@ def _confirm_selection_guards( ) -> bool: """Prompt before saving a model that trips any selection guard. - Runs the unified guard registry (cost + data-policy + future guards) via - :mod:`hermes_cli.model_selection_guards` and shows one [y/N] confirm with - every warning that fired. Returns True to proceed, False to cancel. + 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 ( @@ -8438,6 +7923,140 @@ def _confirm_selection_guards( 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 = "", @@ -8449,19 +8068,12 @@ def _prompt_model_selection( confirm_base_url: str = "", confirm_api_key: str = "", ) -> Optional[str]: - """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None. + """Interactive model picker; current_model listed first. Returns the chosen model ID or None. - If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact - price indicator is shown next to each model in aligned columns. - - If *unavailable_models* is provided, those models are shown grayed out - and unselectable, with an upgrade link to *portal_url*. + 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 - from hermes_cli.models import ( - _format_price_per_mtok, - compute_sale_discount, - ) _unavailable = unavailable_models or [] # Sale chrome (★ / -N% / was) is Nous Portal-only — never for OpenRouter @@ -8495,140 +8107,26 @@ def _prompt_model_selection( ordered.append(mid) # All models for column-width computation (selectable + unavailable) - all_models = list(ordered) + list(_unavailable) - - # Column-aligned labels when pricing is available - 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 - name_col = ( - max((len(m) for m in all_models), default=0) + name_pad - if has_pricing - else 0 + rows = _ModelPickerRows( + list(ordered) + list(_unavailable), pricing, + current_model=current_model, sale_chrome=sale_chrome, ) - - # Pre-compute formatted prices and sale chrome. - # (inp, out, cache, pct|None, was_inp, was_out) - # Sale chrome is drawn as curses/ANSI segments (yellow % / dim "was"), - # not baked into a single plain string — curses addnstr would otherwise - # render escape bytes literally. - _price_cache: dict[str, tuple[str, str, str, int | None, str, str]] = {} - price_col = 3 # minimum width - cache_col = 0 # only set if any model has cache pricing - has_cache = False - any_on_sale = False _DIM = "\033[2m" _RESET = "\033[0m" - if has_pricing: - 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: - has_cache = True - if sale_chrome: - sale = compute_sale_discount( - p.get("prompt", ""), - p.get("completion", ""), - p.get("original"), - ) - if sale is not None: - 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 = "", "", "" - _price_cache[mid] = (inp, out, cache, pct, was_inp, was_out) - price_col = max(price_col, len(inp), len(out)) - cache_col = max(cache_col, len(cache)) - if has_cache: - cache_col = max(cache_col, 5) # minimum: "Cache" header - - def _label_segments(mid): - """Build a rich radiolist row: yellow ★/% , dim was, plain prices.""" - if not has_pricing: - segs: list[tuple[str, str | None]] = [(mid, None)] - if mid == current_model: - segs.append((" ← currently in use", None)) - return segs - - inp, out, cache, pct, was_inp, was_out = _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:<{name_col - star_w}}", None), - ] - else: - name_segs = [(f"{mid:<{name_col}}", None)] - - price_part = f" {inp:>{price_col}} {out:>{price_col}}" - if has_cache: - price_part += f" {cache:>{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 == current_model: - segs.append((" ← currently in use", None)) - return segs - - def _label(mid): - return "".join(text for text, _style in _label_segments(mid)) # Default cursor on the current model (index 0 if it was reordered to top) default_idx = 0 - - # Build a pricing header hint for the menu title - menu_title = "Select default model:" - if 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}{'':>{name_col}} {'In':>{price_col}} {'Out':>{price_col}}" - if has_cache: - header += f" {'Cache':>{cache_col}}" - # Legend lives on the column-header line so it reads as a key - # (★ = on sale), not a fake menu row. - menu_title += header + " $/Mtok" - if any_on_sale: - menu_title += " ★ = on sale" + 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 = [_label_segments(mid) for mid in ordered] + choices = [rows.segments(mid) for mid in ordered] choices.append("Enter custom model name") choices.append("Skip (keep current)") - _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") unavailable_footer = unavailable_message.strip() if not unavailable_footer and _unavailable: unavailable_footer = f"Upgrade at {_upgrade_url} for paid models" @@ -8637,7 +8135,7 @@ def _prompt_model_selection( # 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 has_pricing: + if rows.has_pricing: # menu_title is "Select default model:\n

$/Mtok\n…" # Keep only the header/legend portion for the description. header_part = menu_title.split("\n", 1) @@ -8645,7 +8143,7 @@ def _prompt_model_selection( desc_lines.extend(header_part[1].splitlines()) if _unavailable: for mid in _unavailable: - desc_lines.append(f" {_label(mid)}") + desc_lines.append(f" {rows.label(mid)}") desc_lines.append(f" ── {unavailable_footer} ──") description = "\n".join(desc_lines) if desc_lines else None @@ -8655,7 +8153,7 @@ def _prompt_model_selection( model_search_labels = [] for mid in ordered: - label = _label(mid) + 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. @@ -8700,20 +8198,19 @@ def _prompt_model_selection( 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(_label_segments(mid))}") + 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: - _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") 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}{_label(mid)}{_RESET}") + print(f" {'':>{num_width}} {_DIM}{rows.label(mid)}{_RESET}") print() while True: @@ -8739,8 +8236,8 @@ def _prompt_model_selection( 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. + 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 @@ -8761,6 +8258,56 @@ def login_command(args) -> None: 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, @@ -8772,30 +8319,15 @@ def _login_openai_codex( del args, pconfig # kept for parity with other provider login helpers # Check for existing Hermes-owned credentials - if not force_new_login: - try: - existing = resolve_codex_runtime_credentials() - # Verify the resolved token is actually usable (not expired). - # resolve_codex_runtime_credentials attempts refresh, so if we get - # here the token should be valid — but double-check before telling - # the user "Login successful!". - _resolved_key = existing.get("api_key", "") - if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60): - print("Existing Codex credentials found in Hermes auth store.") - try: - reuse = input("Use existing credentials? [Y/n]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - reuse = "y" - if reuse in {"", "y", "yes"}: - config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) - print() - print("Login successful!") - print(f" Config updated: {config_path} (model.provider=openai-codex)") - return - else: - print("Existing Codex credentials are expired. Starting fresh login...") - except AuthError: - pass + 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: @@ -8803,14 +8335,11 @@ def _login_openai_codex( 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.") - try: - do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - do_import = "n" - if do_import in {"y", "yes"}: + if _prompt_yes_no( + "Import these credentials? (a separate login is recommended) [y/N]: ", default="n", + ): _save_codex_tokens(cli_tokens) - base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL - config_path = _update_config_for_provider("openai-codex", base_url) + 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.") @@ -8828,11 +8357,7 @@ def _login_openai_codex( # 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() - print("Login successful!") - from hermes_constants import display_hermes_home as _dhh - print(f" Auth state: {_dhh()}/auth.json") - print(f" Config updated: {config_path} (model.provider=openai-codex)") + _print_login_success("openai-codex", config_path, show_auth_state=True) def _login_xai_oauth( @@ -8843,27 +8368,14 @@ def _login_xai_oauth( ) -> None: del pconfig - if not force_new_login: - try: - existing = resolve_xai_oauth_runtime_credentials() - api_key = existing.get("api_key", "") - if isinstance(api_key, str) and api_key and not _xai_access_token_is_expiring(api_key, 60): - print("Existing xAI OAuth credentials found in Hermes auth store.") - try: - reuse = input("Use existing credentials? [Y/n]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - reuse = "y" - if reuse in {"", "y", "yes"}: - config_path = _update_config_for_provider( - "xai-oauth", - existing.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL), - ) - print() - print("Login successful!") - print(f" Config updated: {config_path} (model.provider=xai-oauth)") - return - except AuthError: - pass + 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+)...") @@ -8896,11 +8408,7 @@ def _login_xai_oauth( # 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() - print("Login successful!") - from hermes_constants import display_hermes_home as _dhh - print(f" Auth state: {_dhh()}/auth.json") - print(f" Config updated: {config_path} (model.provider=xai-oauth)") + _print_login_success("xai-oauth", config_path, show_auth_state=True) def _xai_oauth_request_device_code( @@ -8910,21 +8418,17 @@ def _xai_oauth_request_device_code( ) -> Dict[str, Any]: response = client.post( XAI_OAUTH_DEVICE_CODE_URL, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, + headers=_FORM_JSON_HEADERS, data={ "client_id": XAI_OAUTH_CLIENT_ID, "scope": scope, }, ) if response.status_code != 200: - raise AuthError( + raise _xai_err( f"xAI device-code request failed (HTTP {response.status_code})." + (f" Response: {response.text.strip()}" if response.text else ""), - provider="xai-oauth", - code="device_code_request_failed", + "device_code_request_failed", ) payload = response.json() required = ( @@ -8937,10 +8441,9 @@ def _xai_oauth_request_device_code( ) missing = [key for key in required if key not in payload] if missing: - raise AuthError( + raise _xai_err( f"xAI device-code response missing fields: {', '.join(missing)}", - provider="xai-oauth", - code="device_code_invalid", + "device_code_invalid", ) return payload @@ -8953,68 +8456,47 @@ def _xai_oauth_poll_device_token( expires_in: int, poll_interval: int, ) -> Dict[str, Any]: - deadline = time.monotonic() + max(1, int(expires_in)) - current_interval = max(1, int(poll_interval)) - while time.monotonic() < deadline: - response = client.post( - token_endpoint, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "client_id": XAI_OAUTH_CLIENT_ID, - "device_code": device_code, - }, - ) - if response.status_code == 200: - payload = response.json() - if not payload.get("access_token"): - raise AuthError( - "xAI device-code token response did not include an access_token.", - provider="xai-oauth", - code="xai_device_token_invalid", + 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", ) - if not payload.get("refresh_token"): - raise AuthError( - "xAI device-code token response did not include a refresh_token.", - provider="xai-oauth", - code="xai_device_token_invalid", - ) - return payload - try: - error_payload = response.json() - except Exception: - response.raise_for_status() - raise AuthError( - "xAI device-code token polling returned a non-JSON error response.", - provider="xai-oauth", - code="xai_device_token_failed", - ) - 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 + def _error(response, error_payload) -> Exception: description = ( error_payload.get("error_description") or error_payload.get("error") or response.text ) - raise AuthError( + return _xai_err( f"xAI device-code token polling failed: {description}", - provider="xai-oauth", - code="xai_device_token_failed", + "xai_device_token_failed", ) - raise AuthError( - "Timed out waiting for xAI device authorization.", - provider="xai-oauth", - code="device_code_timeout", + + 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", + ), ) @@ -9036,19 +8518,12 @@ def _xai_oauth_device_code_login( expires_in = int(device_data["expires_in"]) interval = int(device_data["interval"]) - print() - print("To continue:") - print(f" 1. Open: {verification_url}") - print(f" 2. If prompted, enter code: {user_code}") - if open_browser and not _is_remote_session() and _can_open_graphical_browser(): - try: - opened = webbrowser.open(verification_url) - except Exception: - opened = False - if opened: - print(" (Opened browser for verification)") - else: - print(" Could not open browser automatically -- use the URL above.") + _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( @@ -9062,43 +8537,42 @@ def _xai_oauth_device_code_login( 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 AuthError( + raise _xai_err( "xAI device-code token response was missing required tokens.", - provider="xai-oauth", - code="xai_device_token_invalid", + "xai_device_token_invalid", ) - base_url = _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, - ) + base_url = _xai_oauth_inference_base_url() return { - "tokens": { - "access_token": access_token, - "refresh_token": refresh_token, - "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", - }, + "tokens": _xai_tokens_from_payload(payload, access_token, refresh_token), "discovery": discovery, "redirect_uri": "", "base_url": base_url, - "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "last_refresh": _utc_now_z(), "source": "oauth-device-code", } -def _codex_device_code_login() -> Dict[str, Any]: - """Run the OpenAI device code login flow and return credentials dict.""" - import time as _time +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, + ) - issuer = "https://auth.openai.com" - client_id = CODEX_OAUTH_CLIENT_ID - # Step 1: Request device code. 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 instead of a bare status. +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): @@ -9110,10 +8584,7 @@ def _codex_device_code_login() -> Dict[str, Any]: headers={"Content-Type": "application/json"}, ) except Exception as exc: - raise AuthError( - f"Failed to request device code: {exc}", - provider="openai-codex", code="device_code_request_failed", - ) + raise _codex_err(f"Failed to request device code: {exc}", "device_code_request_failed") if resp.status_code != 429: break @@ -9130,57 +8601,37 @@ def _codex_device_code_login() -> Dict[str, Any]: "OpenAI is rate-limiting login requests " f"(429); retrying in {delay}s..." ) - _time.sleep(delay) + time.sleep(delay) if resp is not None and resp.status_code == 429: - retry_after = _parse_retry_after_seconds(getattr(resp, "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." - ) - raise AuthError( - "OpenAI is rate-limiting Codex login requests (HTTP 429). " - "This is a temporary throttle on OpenAI's side, not a credential " - f"problem.{wait_hint}", - provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, - ) + 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 AuthError( + raise _codex_err( f"Device code request returned status {status}.", - provider="openai-codex", code="device_code_request_error", + "device_code_request_error", ) device_data = resp.json() - user_code = device_data.get("user_code", "") - device_auth_id = device_data.get("device_auth_id", "") - poll_interval = max(3, int(device_data.get("interval", "5"))) + 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 - if not user_code or not device_auth_id: - raise AuthError( - "Device code response missing required fields.", - provider="openai-codex", code="device_code_incomplete", - ) - # 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)") - - # Step 3: Poll for authorization code +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() + 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) + 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}, @@ -9193,29 +8644,31 @@ def _codex_device_code_login() -> Dict[str, Any]: elif poll_resp.status_code in {403, 404}: continue # User hasn't completed login yet else: - raise AuthError( + raise _codex_err( f"Device auth polling returned status {poll_resp.status_code}.", - provider="openai-codex", code="device_code_poll_error", + "device_code_poll_error", ) except KeyboardInterrupt: print("\nLogin cancelled.") raise SystemExit(130) if code_resp is None: - raise AuthError( - "Login timed out after 15 minutes.", - provider="openai-codex", code="device_code_timeout", - ) + raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout") + return code_resp - # Step 4: Exchange authorization code for tokens + +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 AuthError( + raise _codex_err( "Device auth response missing authorization_code or code_verifier.", - provider="openai-codex", code="device_code_incomplete_exchange", + "device_code_incomplete_exchange", ) try: @@ -9232,56 +8685,57 @@ def _codex_device_code_login() -> Dict[str, Any]: headers={"Content-Type": "application/x-www-form-urlencoded"}, ) except Exception as exc: - raise AuthError( - f"Token exchange failed: {exc}", - provider="openai-codex", code="token_exchange_failed", - ) + raise _codex_err(f"Token exchange failed: {exc}", "token_exchange_failed") if token_resp.status_code == 429: - retry_after = _parse_retry_after_seconds( - getattr(token_resp, "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." - ) - raise AuthError( - "OpenAI is rate-limiting Codex login requests (HTTP 429) during " - "token exchange. This is a temporary throttle on OpenAI's side, " - f"not a credential problem.{wait_hint}", - provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, - ) + raise _codex_login_rate_limited_error(token_resp, during=" during token exchange") if token_resp.status_code != 200: - raise AuthError( + raise _codex_err( f"Token exchange returned status {token_resp.status_code}.", - provider="openai-codex", code="token_exchange_error", + "token_exchange_error", ) tokens = token_resp.json() - access_token = tokens.get("access_token", "") - refresh_token = tokens.get("refresh_token", "") - - if not access_token: - raise AuthError( + if not tokens.get("access_token", ""): + raise _codex_err( "Token exchange did not return an access_token.", - provider="openai-codex", code="token_exchange_no_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/) - base_url = ( - os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") - or DEFAULT_CODEX_BASE_URL - ) - return { "tokens": { - "access_token": access_token, - "refresh_token": refresh_token, + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), }, - "base_url": base_url, - "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "base_url": _codex_base_url(), + "last_refresh": _utc_now_z(), "auth_mode": "chatgpt", "source": "device-code", } @@ -9377,30 +8831,23 @@ def _minimax_request_user_code( "code_challenge_method": "S256", "state": state, }, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - "x-request-id": str(uuid.uuid4()), - }, + headers={**_FORM_JSON_HEADERS, "x-request-id": str(uuid.uuid4())}, ) if response.status_code != 200: body = _minimax_response_error_text(response) - raise AuthError( + raise _minimax_err( f"MiniMax OAuth authorization failed: {body or response.reason_phrase}", - provider="minimax-oauth", code="authorization_failed", + "authorization_failed", ) payload = response.json() for field in ("user_code", "verification_uri", "expired_in"): if field not in payload: - raise AuthError( + raise _minimax_err( f"MiniMax OAuth response missing field: {field}", - provider="minimax-oauth", code="authorization_incomplete", + "authorization_incomplete", ) if payload.get("state") != state: - raise AuthError( - "MiniMax OAuth state mismatch (possible CSRF).", - provider="minimax-oauth", code="state_mismatch", - ) + raise _minimax_err("MiniMax OAuth state mismatch (possible CSRF).", "state_mismatch") return payload @@ -9418,22 +8865,27 @@ def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> flo 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. - import time as _time - now_ms = int(_time.time() * 1000) - raw = int(expired_in) - if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms): - deadline = raw / 1000.0 - else: - deadline = _time.time() + max(1, raw) + 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: + while time.time() < deadline: response = _minimax_post_form( client, f"{portal_base_url}/oauth/token", @@ -9443,10 +8895,7 @@ def _minimax_poll_token( "user_code": user_code, "code_verifier": code_verifier, }, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, + headers=_FORM_JSON_HEADERS, ) error_text = "" if response.status_code != 200: @@ -9456,10 +8905,7 @@ def _minimax_poll_token( except Exception: payload = {} msg = (payload.get("base_resp", {}) or {}).get("status_msg") or error_text - raise AuthError( - f"MiniMax OAuth error: {msg or 'unknown'}", - provider="minimax-oauth", code="token_exchange_failed", - ) + raise _minimax_err(f"MiniMax OAuth error: {msg or 'unknown'}", "token_exchange_failed") try: payload = response.json() if response.text else {} except Exception: @@ -9467,32 +8913,26 @@ def _minimax_poll_token( status = payload.get("status") if status == "error": - raise AuthError( + raise _minimax_err( "MiniMax OAuth reported an error. Please try again later.", - provider="minimax-oauth", code="authorization_denied", + "authorization_denied", ) if status == "success": if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")): - raise AuthError( + raise _minimax_err( "MiniMax OAuth success payload missing required token fields.", - provider="minimax-oauth", code="token_incomplete", + "token_incomplete", ) return payload # "pending" or any other status -> keep polling - _time.sleep(interval) + time.sleep(interval) - raise AuthError( - "MiniMax OAuth timed out before authorization completed.", - provider="minimax-oauth", code="timeout", - ) + 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).""" - with _auth_store_lock(): - auth_store = _load_auth_store() - _save_provider_state(auth_store, "minimax-oauth", auth_state) - _save_auth_store(auth_store) + _save_active_provider_state("minimax-oauth", auth_state) def _minimax_oauth_login( @@ -9527,15 +8967,11 @@ def _minimax_oauth_login( verification_url = str(code_data["verification_uri"]) user_code = str(code_data["user_code"]) - print() - print("To continue:") - print(f" 1. Open: {verification_url}") - print(f" 2. If prompted, enter code: {user_code}") - if open_browser and _can_open_graphical_browser(): - if webbrowser.open(verification_url): - print(" (Opened browser for verification)") - else: - print(" Could not open browser automatically -- use the URL above.") + _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 @@ -9549,12 +8985,6 @@ def _minimax_oauth_login( interval_ms=interval_ms, ) - now = datetime.now(timezone.utc) - expires_at_unix = _minimax_resolve_token_expiry_unix( - int(token_data["expired_in"]), now=now, - ) - expires_in_s = max(0, int(expires_at_unix - now.timestamp())) - auth_state = { "provider": "minimax-oauth", "region": region, @@ -9566,9 +8996,7 @@ def _minimax_oauth_login( "access_token": token_data["access_token"], "refresh_token": token_data["refresh_token"], "resource_url": token_data.get("resource_url"), - "obtained_at": now.isoformat(), - "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), - "expires_in": expires_in_s, + **_minimax_expiry_fields(token_data["expired_in"]), } _minimax_save_auth_state(auth_state) @@ -9584,9 +9012,9 @@ def _refresh_minimax_oauth_state( ) -> Dict[str, Any]: """Refresh MiniMax OAuth access token if close to expiry (or forced).""" if not state.get("refresh_token"): - raise AuthError( + raise _minimax_err( "MiniMax OAuth state has no refresh_token; please re-login.", - provider="minimax-oauth", code="no_refresh_token", relogin_required=True, + "no_refresh_token", relogin=True, ) try: expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp() @@ -9607,10 +9035,7 @@ def _refresh_minimax_oauth_state( "client_id": state["client_id"], "refresh_token": state["refresh_token"], }, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, + 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 @@ -9621,30 +9046,21 @@ def _refresh_minimax_oauth_state( body_lower = body.lower() relogin = any(m in body_lower for m in ("invalid_grant", "refresh_token_reused", "invalid_refresh_token")) - raise AuthError( + raise _minimax_err( f"MiniMax OAuth refresh failed: {body or response.reason_phrase}", - provider="minimax-oauth", code="refresh_failed", - relogin_required=relogin, + "refresh_failed", relogin=relogin, ) payload = response.json() if payload.get("status") != "success": - raise AuthError( + raise _minimax_err( "MiniMax OAuth refresh did not return success.", - provider="minimax-oauth", code="refresh_failed", - relogin_required=True, + "refresh_failed", relogin=True, ) - now_dt = datetime.now(timezone.utc) - expires_at_unix = _minimax_resolve_token_expiry_unix( - int(payload["expired_in"]), now=now_dt, - ) - expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp())) new_state = dict(state) new_state.update({ "access_token": payload["access_token"], "refresh_token": payload.get("refresh_token", state["refresh_token"]), - "obtained_at": now_dt.isoformat(), - "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(), - "expires_in": expires_in_s, + **_minimax_expiry_fields(payload["expired_in"]), }) _minimax_save_auth_state(new_state) return new_state @@ -9653,69 +9069,48 @@ def _refresh_minimax_oauth_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 both the eager-resolve path and the lazy per-request token - provider. Mirrors the Nous / xAI-OAuth / Codex-OAuth quarantine pattern - so subsequent calls fail fast without a network retry. + 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 - for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"): - state.pop(_k, None) - state["last_auth_error"] = { - "provider": "minimax-oauth", - "code": exc.code or "refresh_failed", - "message": str(exc), - "reason": "runtime_refresh_failure", - "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } + _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. - - Returning a *callable* instead of a string lets us hook into the - existing Entra-ID bearer infrastructure in - :mod:`agent.anthropic_adapter`: ``build_anthropic_client`` detects a - callable and routes through ``_build_anthropic_client_with_bearer_hook``, - which mints a fresh ``Authorization`` header on every outbound request. - Each invocation re-reads the persisted state from ``auth.json`` and - calls :func:`_refresh_minimax_oauth_state` — that helper is a no-op - when the token still has more than ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` - of life left, so the steady-state cost is one file read + one - timestamp compare per request. - - Reading state fresh each time also means a refresh persisted by one - process (CLI, gateway, cron) is immediately visible to every other - process sharing the same ``auth.json``. + 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 = get_provider_auth_state("minimax-oauth") - if not state or not state.get("access_token"): - raise AuthError( - "Not logged into MiniMax OAuth. Run `hermes model` and select " - "MiniMax (OAuth).", - provider="minimax-oauth", code="not_logged_in", relogin_required=True, - ) - try: - state = _refresh_minimax_oauth_state(state) - except AuthError as exc: - _minimax_oauth_quarantine_on_terminal_refresh(state, exc) - raise + state = _minimax_fresh_state() token = state.get("access_token") if not token: - raise AuthError( + raise _minimax_err( "MiniMax OAuth state has no access_token after refresh.", - provider="minimax-oauth", code="no_access_token", relogin_required=True, + "no_access_token", relogin=True, ) return token @@ -9728,29 +9123,10 @@ def resolve_minimax_oauth_runtime_credentials( ) -> Dict[str, Any]: """Return {provider, api_key, base_url, source} for minimax-oauth. - When ``as_token_provider`` is True, ``api_key`` is a zero-arg callable - that mints a fresh access token per call (proactively refreshing if - the cached token is within ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of - expiry). This is what the runtime provider path uses so that long - sessions survive MiniMax's short access-token lifetime — see - :func:`build_minimax_oauth_token_provider` for the rationale. - - 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. + 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 = get_provider_auth_state("minimax-oauth") - if not state or not state.get("access_token"): - raise AuthError( - "Not logged into MiniMax OAuth. Run `hermes model` and select " - "MiniMax (OAuth).", - provider="minimax-oauth", code="not_logged_in", relogin_required=True, - ) - try: - state = _refresh_minimax_oauth_state(state) - except AuthError as exc: - _minimax_oauth_quarantine_on_terminal_refresh(state, exc) - raise + state = _minimax_fresh_state() if as_token_provider: api_key: Any = build_minimax_oauth_token_provider() else: @@ -9848,17 +9224,9 @@ def _nous_device_code_login( expires_in = int(device_data["expires_in"]) interval = int(device_data["interval"]) - print() - print("To continue:") - print(f" 1. Open: {verification_url}") - print(f" 2. If prompted, enter code: {user_code}") - - if open_browser: - opened = webbrowser.open(verification_url) - if opened: - print(" (Opened browser for verification)") - else: - print(" Could not open browser automatically — use the URL above.") + _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 @@ -9903,16 +9271,8 @@ def _nous_device_code_login( "obtained_at": now.isoformat(), "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(), "expires_in": token_expires_in, - "tls": { - "insecure": verify is False, - "ca_bundle": verify if isinstance(verify, str) else None, - }, - "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, + "tls": _tls_state_from_verify(verify), + **_NOUS_EMPTY_AGENT_KEY_FIELDS, } try: return refresh_nous_oauth_from_state( @@ -9935,22 +9295,16 @@ def _nous_device_code_login( raise -def nous_token_has_billing_scope() -> bool: - """Return True if the currently-held Nous token carries ``billing:manage``. - - Reads the persisted ``scope`` string saved at login (``_save_provider_state`` - stores ``token_data.get("scope") or scope``). A space-delimited match. Used by - the lazy step-up: if False, the first billing call will 403 ``insufficient_scope`` - anyway, but checking up front lets a surface skip a doomed round-trip. - """ +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: - state = get_provider_auth_state("nous") or {} + _write_shared_nous_state(auth_state) except Exception: - return False - scope = state.get("scope") - if not isinstance(scope, str): - return False - return NOUS_BILLING_MANAGE_SCOPE in scope.split() + pass + try: + _sync_nous_pool_from_auth_store() + except Exception: + pass def step_up_nous_billing_scope( @@ -9961,20 +9315,10 @@ def step_up_nous_billing_scope( ) -> bool: """Re-run the device flow requesting ``billing:manage`` and persist the result. - The lazy step-up (plan D-A): triggered when a billing endpoint returns - ``403 insufficient_scope``. Runs a fresh device-connect with - ``inference:invoke tool:invoke billing:manage`` on the scope. The user must be - an ADMIN/OWNER and select "Allow Remote Spending" in the portal for the minted - token to actually carry the scope; 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 (incl. a preview via ``HERMES_PORTAL_BASE_URL`` set - at the original login). Persists to the auth store + shared store + pool, exactly - like ``_login_nous`` — but WITHOUT the model picker (this is a scope upgrade, not - a fresh login). - - Returns True iff the new token carries ``billing:manage``. + 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"] @@ -10001,25 +9345,134 @@ def step_up_nous_billing_scope( on_verification=on_verification, ) - with _auth_store_lock(): - auth_store = _load_auth_store() - _save_provider_state(auth_store, "nous", auth_state) - _save_auth_store(auth_store) - - # Mirror to shared store + reseed the pool (best-effort), same as _login_nous. - try: - _write_shared_nous_state(auth_state) - except Exception: - pass - try: - _sync_nous_pool_from_auth_store() - except Exception: - pass + _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 @@ -10031,34 +9484,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: ) try: - auth_state = None - - # Codex-style auto-import: before launching a fresh device-code - # flow, check the shared store for an existing Nous credential - # from any other profile. If present, offer to rehydrate it. - shared = _read_shared_nous_state() - if shared: - 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") - try: - do_import = input("Import these credentials? [Y/n]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - do_import = "y" - if do_import in {"", "y", "yes"}: - 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.") - + 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), @@ -10081,10 +9507,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: _prior_store = _load_auth_store() prior_active_provider = _prior_store.get("active_provider") - with _auth_store_lock(): - auth_store = _load_auth_store() - _save_provider_state(auth_store, "nous", auth_state) - saved_to = _save_auth_store(auth_store) + 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 @@ -10102,105 +9525,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: # OpenRouter). The auth.json active_provider was already set above. selected_model = None try: - runtime_key = auth_state.get("agent_key") or auth_state.get("access_token") - if not isinstance(runtime_key, str) or not runtime_key: - raise AuthError( - "No runtime API key available to fetch models", - provider="nous", - code="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 freeRecommendedModels endpoint is the - # source of truth for what's free *right now*. Augment - # the curated list with anything new the Portal flags - # as free so users on older Hermes builds still see - # newly-launched free models without a CLI release. - model_ids, pricing = union_with_portal_free_recommendations( - 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 - model_ids, unavailable_models = partition_nous_models_by_tier( - model_ids, pricing, free_tier=True, - ) - else: - # Paid-tier mirror: pull paidRecommendedModels so newly - # launched paid models surface in the picker even if - # the in-repo curated list and docs-hosted manifest - # haven't caught up yet. - model_ids, pricing = union_with_portal_paid_recommendations( - 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 - _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.") - selected_model = _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.") + 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() diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 3699032885..883191f337 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -6,7 +6,9 @@ from hermes_cli.cli_output import line_input import math import sys import time +from dataclasses import dataclass from types import SimpleNamespace +from typing import Any, Callable import uuid from agent.credential_pool import ( @@ -53,26 +55,15 @@ def _get_custom_provider_entries() -> list[dict]: name = entry.get("name") if not isinstance(name, str) or not name.strip(): continue - normalized = dict(entry) - normalized["name"] = name.strip() - normalized["pool_key"] = ( - f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}" - ) - normalized["provider_key"] = str( - entry.get("provider_key", "") or "" - ).strip() - result.append(normalized) + result.append({ + **entry, + "name": name.strip(), + "pool_key": f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}", + "provider_key": str(entry.get("provider_key", "") or "").strip(), + }) return result -def _get_custom_provider_names() -> list: - """Return list of (display_name, pool_key, provider_key) tuples.""" - return [ - (entry["name"], entry["pool_key"], entry["provider_key"]) - for entry in _get_custom_provider_entries() - ] - - def _configured_provider_entry(provider: str) -> dict | None: """Resolve a canonical ``providers.`` entry.""" normalized = (provider or "").strip().lower() @@ -94,31 +85,32 @@ def _resolve_custom_provider_input(raw: str) -> str | None: if normalized.startswith(CUSTOM_POOL_PREFIX): return normalized for entry in _get_custom_provider_entries(): - display_name = entry["name"] - pool_key = entry["pool_key"] - provider_key = entry["provider_key"] # ``providers:`` entries already have a durable runtime slug. Keep # credentials under that slug instead of leaking the legacy # ``custom:`` compatibility identity into auth.json and discovery. - normalized_provider_key = provider_key.strip().lower() - if normalized_provider_key and normalized_provider_key == normalized: - return normalized_provider_key - if _normalize_custom_pool_name(display_name) == normalized: - return normalized_provider_key or pool_key + provider_key = entry["provider_key"].strip().lower() + if provider_key and provider_key == normalized: + return provider_key + if _normalize_custom_pool_name(entry["name"]) == normalized: + return provider_key or entry["pool_key"] return None +_PROVIDER_ALIASES = { + "or": "openrouter", + "open-router": "openrouter", + "grok-oauth": "xai-oauth", + "xai-oauth": "xai-oauth", + "x-ai-oauth": "xai-oauth", + "xai-grok-oauth": "xai-oauth", +} + + def _normalize_provider(provider: str) -> str: normalized = (provider or "").strip().lower() - if normalized in {"or", "open-router"}: - return "openrouter" - if normalized in {"grok-oauth", "xai-oauth", "x-ai-oauth", "xai-grok-oauth"}: - return "xai-oauth" - # Check if it matches a custom provider name - custom_key = _resolve_custom_provider_input(normalized) - if custom_key: - return custom_key - return normalized + if normalized in _PROVIDER_ALIASES: + return _PROVIDER_ALIASES[normalized] + return _resolve_custom_provider_input(normalized) or normalized def _migrate_legacy_custom_pool_key(provider: str, legacy_key: str) -> None: @@ -165,10 +157,7 @@ def _provider_base_url(provider: str) -> str: if provider.startswith(CUSTOM_POOL_PREFIX): from agent.credential_pool import _get_custom_provider_config - cp_config = _get_custom_provider_config(provider) - if cp_config: - return str(cp_config.get("base_url") or "").strip() - return "" + return str((_get_custom_provider_config(provider) or {}).get("base_url") or "").strip() configured = _configured_provider_entry(provider) if configured is not None: return str(configured.get("base_url") or "").strip() @@ -185,37 +174,31 @@ def _is_known_provider(provider: str, configured_provider: dict | None) -> bool: ) -def _oauth_default_label(provider: str, count: int) -> str: - return f"{provider}-oauth-{count}" - - -def _api_key_default_label(count: int) -> str: - return f"api-key-{count}" - - def _display_source(source: str) -> str: return source.split(":", 1)[1] if source.startswith("manual:") else source +# (label, show_retry_window, http codes, reason substrings, message substrings) — first match wins. +_EXHAUSTED_CLASSES = ( + ("rate-limited", True, {429}, + ("rate_limit", "usage_limit", "quota", "exhausted"), + ("rate limit", "usage limit", "quota", "too many requests")), + ("auth failed", False, {401, 403}, + ("invalid_token", "invalid_grant", "unauthorized", "forbidden", "auth"), + ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication")), +) + + def _classify_exhausted_status(entry) -> tuple[str, bool]: code = getattr(entry, "last_error_code", None) reason = str(getattr(entry, "last_error_reason", "") or "").strip().lower() message = str(getattr(entry, "last_error_message", "") or "").strip().lower() - - if code == 429 or any(token in reason for token in ("rate_limit", "usage_limit", "quota", "exhausted")) or any( - token in message for token in ("rate limit", "usage limit", "quota", "too many requests") - ): - return "rate-limited", True - - if code in {401, 403} or any(token in reason for token in ("invalid_token", "invalid_grant", "unauthorized", "forbidden", "auth")) or any( - token in message for token in ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication") - ): - return "auth failed", False - + for label, retry_window, codes, reason_tokens, message_tokens in _EXHAUSTED_CLASSES: + if code in codes or any(t in reason for t in reason_tokens) or any(t in message for t in message_tokens): + return label, retry_window return "exhausted", True - def _format_exhausted_status(entry) -> str: if entry.last_status != STATUS_EXHAUSTED: return "" @@ -234,17 +217,189 @@ def _format_exhausted_status(entry) -> str: minutes, seconds = divmod(remaining, 60) hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) - if days: - wait = f"{days}d {hours}h" - elif hours: - wait = f"{hours}h {minutes}m" - elif minutes: - wait = f"{minutes}m {seconds}s" - else: - wait = f"{seconds}s" + parts = [(days, "d"), (hours, "h"), (minutes, "m"), (seconds, "s")] + first = next(i for i, (value, _) in enumerate(parts) if value or i == 3) + wait = " ".join(f"{value}{unit}" for value, unit in parts[first:first + 2]) return f" {label}{reason_text}{code} ({wait} left)" +def _anthropic_oauth_login(args) -> dict: + from agent import anthropic_adapter as anthropic_mod + + creds = anthropic_mod.run_hermes_oauth_login_pure() + if not creds: + raise SystemExit("Anthropic OAuth login did not return credentials.") + return creds + + +def _qwen_oauth_login(args) -> dict: + creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) + auth_mod._mark_qwen_oauth_active(creds) + return creds + + +@dataclass(frozen=True) +class _OAuthAddSpec: + """Per-provider parameters for the generic ``hermes auth add --type oauth`` path.""" + + login: Callable[[Any], dict] + token: Callable[[dict], str] + source: str + fields: Callable[[dict, str], dict] + activate_first: bool = False + + +_OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = { + "anthropic": _OAuthAddSpec( + login=_anthropic_oauth_login, + token=lambda creds: creds["access_token"], + source=f"{SOURCE_MANUAL}:hermes_pkce", + fields=lambda creds, provider: { + "refresh_token": creds.get("refresh_token"), + "expires_at_ms": creds.get("expires_at_ms"), + "base_url": _provider_base_url(provider), + }, + ), + "openai-codex": _OAuthAddSpec( + login=lambda args: auth_mod._codex_device_code_login(), + token=lambda creds: creds["tokens"]["access_token"], + source=SOURCE_MANUAL_DEVICE_CODE, + fields=lambda creds, provider: { + "refresh_token": creds["tokens"].get("refresh_token"), + "base_url": creds.get("base_url"), + "last_refresh": creds.get("last_refresh"), + }, + activate_first=True, + ), + "xai-oauth": _OAuthAddSpec( + login=lambda args: auth_mod._xai_oauth_device_code_login( + timeout_seconds=getattr(args, "timeout", None) or 20.0, + open_browser=not getattr(args, "no_browser", False), + ), + token=lambda creds: creds["tokens"]["access_token"], + source=SOURCE_MANUAL_DEVICE_CODE, + fields=lambda creds, provider: { + "refresh_token": creds["tokens"].get("refresh_token"), + "base_url": creds.get("base_url") or auth_mod.DEFAULT_XAI_OAUTH_BASE_URL, + "last_refresh": creds.get("last_refresh"), + }, + activate_first=True, + ), + "qwen-oauth": _OAuthAddSpec( + login=_qwen_oauth_login, + token=lambda creds: creds["api_key"], + source=f"{SOURCE_MANUAL}:qwen_cli", + fields=lambda creds, provider: {"base_url": creds.get("base_url")}, + ), + "minimax-oauth": _OAuthAddSpec( + login=lambda args: auth_mod._minimax_oauth_login( + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=getattr(args, "timeout", None) or 15.0, + ), + token=lambda creds: creds["access_token"], + source=f"{SOURCE_MANUAL}:minimax_oauth", + fields=lambda creds, provider: { + "refresh_token": creds.get("refresh_token"), + "base_url": creds.get("inference_base_url"), + }, + ), +} + + +def _add_nous_oauth_credential(args, provider: str) -> None: + """``hermes auth add nous --type oauth``: shared-credential import, else device-code login.""" + custom_label = (getattr(args, "label", None) or "").strip() or None + + def _persist(creds: dict, what: str) -> None: + # Honor `--label ` so nous matches other providers' UX. The + # helper embeds this into providers.nous so that label_from_token + # doesn't overwrite it on every subsequent load_pool("nous"). + entry = auth_mod.persist_nous_credentials(creds, label=custom_label) + shown_label = entry.label if entry is not None else label_from_token( + creds.get("access_token", ""), f"{provider}-oauth-1", + ) + print(f'{what} {provider} OAuth {"device-code " if what == "Saved" else ""}credentials: "{shown_label}"') + + # Codex-style auto-import: if a shared Nous credential lives at + # /shared/nous_auth.json (written by any previous + # successful login), offer to import it instead of running the + # full device-code flow. This makes `hermes --profile + # auth add nous --type oauth` a one-tap operation for users who + # run multiple profiles. + shared = auth_mod._read_shared_nous_state() + if shared: + try: + found = f"Found existing Nous OAuth credentials at {auth_mod._nous_shared_store_path()}" + except RuntimeError: + found = "Found existing shared Nous OAuth credentials" + print() + print(found) + try: + do_import = input("Import these credentials? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + do_import = "y" + if do_import in {"", "y", "yes"}: + print("Rehydrating Nous session from shared credentials...") + rehydrated = auth_mod._try_import_shared_nous_state( + timeout_seconds=getattr(args, "timeout", None) or 15.0, + ) + if rehydrated is not None: + _persist(rehydrated, "Imported") + return + # Rehydrate failed (expired refresh_token, portal down, etc.) + # — fall through to device-code flow. + print("Could not refresh shared credentials — falling back to device-code login.") + + creds = auth_mod._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), + scope=getattr(args, "scope", None), + open_browser=not getattr(args, "no_browser", False), + timeout_seconds=getattr(args, "timeout", None) or 15.0, + insecure=bool(getattr(args, "insecure", False)), + ca_bundle=getattr(args, "ca_bundle", None), + ) + _persist(creds, "Saved") + + +def _unsuppress_provider_sources(provider: str) -> None: + """Clear ALL suppressions for this provider — re-adding a credential is a strong signal the + user wants auth re-enabled. Covers env:* (shell-exported vars), gh_cli (copilot), claude_code, + qwen-cli, device_code (codex), etc. — one consistent re-engagement pattern.""" + try: + suppressed = auth_mod._load_auth_store().get("suppressed_sources", {}) + for src in list(suppressed.get(provider, []) or []): + auth_mod.unsuppress_credential_source(provider, src) + except Exception: + pass + + +def _add_api_key_credential(args, provider: str, pool) -> None: + token = (getattr(args, "api_key", None) or "").strip() + if not token: + token = masked_secret_prompt("Paste your API key: ").strip() + if not token: + raise SystemExit("No API key provided.") + default_label = f"api-key-{len(pool.entries()) + 1}" + label = (getattr(args, "label", None) or "").strip() + if not label and sys.stdin.isatty(): + label = line_input(f"Label (optional, default: {default_label}): ").strip() + label = label or default_label + entry = PooledCredential( + provider=provider, + id=uuid.uuid4().hex[:6], + label=label, + auth_type=AUTH_TYPE_API_KEY, + priority=0, + source=SOURCE_MANUAL, + access_token=token, + base_url=_provider_base_url(provider), + ) + pool.add_entry(entry) + print(f'Added {provider} credential #{len(pool.entries())}: "{label}"') + + def auth_add_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "")) configured_provider = _configured_provider_entry(provider) @@ -257,268 +412,56 @@ def auth_add_command(args) -> None: if requested_type in {AUTH_TYPE_API_KEY, "api-key"}: requested_type = AUTH_TYPE_API_KEY if not requested_type: - if provider.startswith(CUSTOM_POOL_PREFIX): - requested_type = AUTH_TYPE_API_KEY - else: - requested_type = AUTH_TYPE_OAUTH if provider in _OAUTH_CAPABLE_PROVIDERS else AUTH_TYPE_API_KEY + oauth_default = provider in _OAUTH_CAPABLE_PROVIDERS and not provider.startswith(CUSTOM_POOL_PREFIX) + requested_type = AUTH_TYPE_OAUTH if oauth_default else AUTH_TYPE_API_KEY pool = load_pool(provider) - # Clear ALL suppressions for this provider — re-adding a credential is - # a strong signal the user wants auth re-enabled. This covers env:* - # (shell-exported vars), gh_cli (copilot), claude_code, qwen-cli, - # device_code (codex), etc. One consistent re-engagement pattern. - # Matches the Codex device_code re-link pattern that predates this. if not provider.startswith(CUSTOM_POOL_PREFIX): - try: - from hermes_cli.auth import ( - _load_auth_store, - unsuppress_credential_source, - ) - suppressed = _load_auth_store().get("suppressed_sources", {}) - for src in list(suppressed.get(provider, []) or []): - unsuppress_credential_source(provider, src) - except Exception: - pass + _unsuppress_provider_sources(provider) if requested_type == AUTH_TYPE_API_KEY: - token = (getattr(args, "api_key", None) or "").strip() - if not token: - token = masked_secret_prompt("Paste your API key: ").strip() - if not token: - raise SystemExit("No API key provided.") - default_label = _api_key_default_label(len(pool.entries()) + 1) - label = (getattr(args, "label", None) or "").strip() - if not label: - if sys.stdin.isatty(): - label = line_input(f"Label (optional, default: {default_label}): ").strip() or default_label - else: - label = default_label - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_API_KEY, - priority=0, - source=SOURCE_MANUAL, - access_token=token, - base_url=_provider_base_url(provider), - ) - pool.add_entry(entry) - print(f'Added {provider} credential #{len(pool.entries())}: "{label}"') - return - - if provider == "anthropic": - from agent import anthropic_adapter as anthropic_mod - - creds = anthropic_mod.run_hermes_oauth_login_pure() - if not creds: - raise SystemExit("Anthropic OAuth login did not return credentials.") - label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["access_token"], - _oauth_default_label(provider, len(pool.entries()) + 1), - ) - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=f"{SOURCE_MANUAL}:hermes_pkce", - access_token=creds["access_token"], - refresh_token=creds.get("refresh_token"), - expires_at_ms=creds.get("expires_at_ms"), - base_url=_provider_base_url(provider), - ) - pool.add_entry(entry) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') + _add_api_key_credential(args, provider, pool) return if provider == "nous": - # Codex-style auto-import: if a shared Nous credential lives at - # /shared/nous_auth.json (written by any previous - # successful login), offer to import it instead of running the - # full device-code flow. This makes `hermes --profile - # auth add nous --type oauth` a one-tap operation for users who - # run multiple profiles. - shared = auth_mod._read_shared_nous_state() - if shared: - try: - path = auth_mod._nous_shared_store_path() - except RuntimeError: - path = None - print() - if path: - print(f"Found existing Nous OAuth credentials at {path}") - else: - print("Found existing shared Nous OAuth credentials") - try: - do_import = input("Import these credentials? [Y/n]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - do_import = "y" - if do_import in {"", "y", "yes"}: - print("Rehydrating Nous session from shared credentials...") - rehydrated = auth_mod._try_import_shared_nous_state( - timeout_seconds=getattr(args, "timeout", None) or 15.0, - ) - if rehydrated is not None: - custom_label = (getattr(args, "label", None) or "").strip() or None - entry = auth_mod.persist_nous_credentials(rehydrated, label=custom_label) - shown_label = entry.label if entry is not None else label_from_token( - rehydrated.get("access_token", ""), _oauth_default_label(provider, 1), - ) - print(f'Imported {provider} OAuth credentials: "{shown_label}"') - return - # Rehydrate failed (expired refresh_token, portal down, etc.) - # — fall through to device-code flow. - print("Could not refresh shared credentials — falling back to device-code login.") - - creds = auth_mod._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), - scope=getattr(args, "scope", None), - open_browser=not getattr(args, "no_browser", False), - timeout_seconds=getattr(args, "timeout", None) or 15.0, - insecure=bool(getattr(args, "insecure", False)), - ca_bundle=getattr(args, "ca_bundle", None), - ) - # Honor `--label ` so nous matches other providers' UX. The - # helper embeds this into providers.nous so that label_from_token - # doesn't overwrite it on every subsequent load_pool("nous"). - custom_label = (getattr(args, "label", None) or "").strip() or None - entry = auth_mod.persist_nous_credentials(creds, label=custom_label) - shown_label = entry.label if entry is not None else label_from_token( - creds.get("access_token", ""), _oauth_default_label(provider, 1), - ) - print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"') + _add_nous_oauth_credential(args, provider) return - if provider == "openai-codex": - creds = auth_mod._codex_device_code_login() - label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["tokens"]["access_token"], - _oauth_default_label(provider, len(pool.entries()) + 1), - ) - # Add a distinct, self-contained pool entry per account (matching the - # qwen-oauth / minimax-oauth multi-account patterns, and the - # xai-oauth path below) instead of routing through the singleton - # ``_save_codex_tokens`` save path. - # The singleton round-trip collapsed every added account into the - # latest login: a second ``hermes auth add openai-codex`` overwrote - # the first account's singleton-mirrored ``device_code`` entry rather - # than creating an independent one (#39236). ``manual:device_code`` - # entries refresh from their own token pair, so they need no singleton - # shadow. - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=SOURCE_MANUAL_DEVICE_CODE, - access_token=creds["tokens"]["access_token"], - refresh_token=creds["tokens"].get("refresh_token"), - base_url=creds.get("base_url"), - last_refresh=creds.get("last_refresh"), - ) - first_credential = not pool.entries() - pool.add_entry(entry) - # Adding the first Codex credential should make it the active provider - # (the old singleton save path did this implicitly via - # _save_provider_state). Subsequent adds leave the active provider as-is. - if first_credential: - auth_mod.mark_provider_active_if_unset(provider) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return + spec = _OAUTH_ADD_SPECS.get(provider) + if spec is None: + raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.") - if provider == "xai-oauth": - creds = auth_mod._xai_oauth_device_code_login( - timeout_seconds=getattr(args, "timeout", None) or 20.0, - open_browser=not getattr(args, "no_browser", False), - ) - label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["tokens"]["access_token"], - _oauth_default_label(provider, len(pool.entries()) + 1), - ) - # Add a distinct, self-contained pool entry per account (matching the - # openai-codex / qwen-oauth / minimax-oauth patterns) instead of - # routing through the singleton ``_save_xai_oauth_tokens`` save path. - # The singleton round-trip collapsed every added account into the - # latest login: a second ``hermes auth add xai-oauth`` overwrote the - # first account's singleton-mirrored ``device_code`` entry rather than - # creating an independent one. ``manual:device_code`` entries refresh - # from their own token pair (``_sync_xai_oauth_entry_from_auth_store`` - # only adopts the singleton for ``source=="device_code"``), so they - # need no singleton shadow. - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=SOURCE_MANUAL_DEVICE_CODE, - access_token=creds["tokens"]["access_token"], - refresh_token=creds["tokens"].get("refresh_token"), - base_url=creds.get("base_url") or auth_mod.DEFAULT_XAI_OAUTH_BASE_URL, - last_refresh=creds.get("last_refresh"), - ) - first_credential = not pool.entries() - pool.add_entry(entry) - # Adding the first xAI credential should make it the active provider - # (the old singleton save path did this implicitly via - # _save_provider_state). Subsequent adds leave the active provider as-is. - if first_credential: - auth_mod.mark_provider_active_if_unset(provider) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return - - if provider == "qwen-oauth": - creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) - auth_mod._mark_qwen_oauth_active(creds) - label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["api_key"], - _oauth_default_label(provider, len(pool.entries()) + 1), - ) - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=f"{SOURCE_MANUAL}:qwen_cli", - access_token=creds["api_key"], - base_url=creds.get("base_url"), - ) - pool.add_entry(entry) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return - - if provider == "minimax-oauth": - creds = auth_mod._minimax_oauth_login( - open_browser=not getattr(args, "no_browser", False), - timeout_seconds=getattr(args, "timeout", None) or 15.0, - ) - label = (getattr(args, "label", None) or "").strip() or label_from_token( - creds["access_token"], - _oauth_default_label(provider, len(pool.entries()) + 1), - ) - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=f"{SOURCE_MANUAL}:minimax_oauth", - access_token=creds["access_token"], - refresh_token=creds.get("refresh_token"), - base_url=creds.get("inference_base_url"), - ) - pool.add_entry(entry) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return - - raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.") + creds = spec.login(args) + token = spec.token(creds) + label = (getattr(args, "label", None) or "").strip() or label_from_token( + token, f"{provider}-oauth-{len(pool.entries()) + 1}", + ) + # Every provider gets a distinct, self-contained pool entry per account + # instead of routing through a singleton save path. The singleton + # round-trip collapsed every added account into the latest login: a + # second ``hermes auth add `` overwrote the first account's + # singleton-mirrored entry rather than creating an independent one + # (#39236). ``manual:*`` entries refresh from their own token pair, so + # they need no singleton shadow. + entry = PooledCredential( + provider=provider, + id=uuid.uuid4().hex[:6], + label=label, + auth_type=AUTH_TYPE_OAUTH, + priority=0, + source=spec.source, + access_token=token, + **spec.fields(creds, provider), + ) + first_credential = not pool.entries() + pool.add_entry(entry) + # Adding the first Codex/xAI credential should make it the active + # provider (the old singleton save path did this implicitly via + # _save_provider_state). Subsequent adds leave the active provider as-is. + if spec.activate_first and first_credential: + auth_mod.mark_provider_active_if_unset(provider) + print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') def auth_list_command(args) -> None: @@ -550,9 +493,7 @@ def auth_list_command(args) -> None: current = pool.peek() print(f"{provider} ({len(entries)} credentials):") for idx, entry in enumerate(entries, start=1): - marker = " " - if current is not None and entry.id == current.id: - marker = "← " + marker = "← " if current is not None and entry.id == current.id else " " status = _format_exhausted_status(entry) source = _display_source(entry.source) print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip()) @@ -569,8 +510,7 @@ def _print_oauth_heal_notices() -> None: def auth_remove_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "")) target = getattr(args, "target", None) - if target is None: - target = getattr(args, "index", None) + target = getattr(args, "index", None) if target is None else target pool = load_pool(provider) index, matched, error = pool.resolve_target(target) if matched is None or index is None: @@ -587,7 +527,6 @@ def auth_remove_command(args) -> None: # user-facing output here so every source behaves identically from # the user's perspective. from agent.credential_sources import find_removal_step - from hermes_cli.auth import suppress_credential_source step = find_removal_step(provider, removed.source) if step is None: @@ -599,7 +538,7 @@ def auth_remove_command(args) -> None: for line in result.cleaned: print(line) if result.suppress: - suppress_credential_source(provider, removed.source) + auth_mod.suppress_credential_source(provider, removed.source) for line in result.hints: print(line) @@ -623,10 +562,7 @@ def auth_status_command(args) -> None: _print_oauth_heal_notices() if not status.get("logged_in"): reason = status.get("error") - if reason: - print(f"{provider}: logged out ({reason})") - else: - print(f"{provider}: logged out") + print(f"{provider}: logged out" + (f" ({reason})" if reason else "")) return print(f"{provider}: logged in") @@ -645,13 +581,73 @@ def auth_spotify_command(args) -> None: if action in {"", "login"}: auth_mod.login_spotify_command(args) return - if action == "status": - auth_status_command(SimpleNamespace(provider="spotify")) - return - if action == "logout": - auth_logout_command(SimpleNamespace(provider="spotify")) - return - raise SystemExit(f"Unknown Spotify auth action: {action}") + handler = {"status": auth_status_command, "logout": auth_logout_command}.get(action) + if handler is None: + raise SystemExit(f"Unknown Spotify auth action: {action}") + handler(SimpleNamespace(provider="spotify")) + + +def _print_bedrock_status() -> None: + """Show AWS Bedrock credential status (not in the pool — uses boto3 chain).""" + try: + from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region + if not has_aws_credentials(): + return + region = resolve_bedrock_region() + print("bedrock (AWS SDK credential chain):") + print(f" Auth: {resolve_aws_auth_env_var() or 'unknown'}") + print(f" Region: {region}") + try: + import boto3 + arn = boto3.client("sts", region_name=region).get_caller_identity().get("Arn", "unknown") + print(f" Identity: {arn}") + except Exception: + print(" Identity: (could not resolve — boto3 STS call failed)") + print() + except ImportError: + pass # boto3 or bedrock_adapter not available + + +def _print_azure_entra_status() -> None: + """Show Azure Foundry Entra ID status when model.provider is azure-foundry with entra_id auth.""" + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else None + if not isinstance(model_cfg, dict) or ( + str(model_cfg.get("provider") or "").strip().lower() != "azure-foundry" + or str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id" + ): + return + from agent.azure_identity_adapter import ( + EntraIdentityConfig, + SCOPE_AI_AZURE_DEFAULT, + describe_active_credential, + has_azure_identity_installed, + ) + base_url = str(model_cfg.get("base_url") or "").strip() + entra = model_cfg.get("entra") or {} + scope = (str(entra.get("scope") or "").strip() if isinstance(entra, dict) else "") or SCOPE_AI_AZURE_DEFAULT + print("azure-foundry (Microsoft Entra ID):") + print(f" Endpoint: {base_url or '(not configured)'}") + print(f" Scope: {scope}") + if not has_azure_identity_installed(): + print(" Status: ⚠ azure-identity not installed " + "(pip install azure-identity)") + else: + info = describe_active_credential( + config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0 + ) + env_sources = info.get("env_sources") or [] + if info.get("ok"): + print(f" Status: ✓ token acquired ({', '.join(env_sources) if env_sources else 'default chain'})") + else: + print(f" Status: ⚠ {info.get('error') or 'credential chain exhausted'}") + if info.get("hint"): + print(f" Hint: {info['hint']}") + print() + except Exception: + pass def _interactive_auth() -> None: @@ -661,75 +657,8 @@ def _interactive_auth() -> None: print("=" * 50) auth_list_command(SimpleNamespace(provider=None)) - - # Show AWS Bedrock credential status (not in the pool — uses boto3 chain) - try: - from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region - if has_aws_credentials(): - auth_source = resolve_aws_auth_env_var() or "unknown" - region = resolve_bedrock_region() - print("bedrock (AWS SDK credential chain):") - print(f" Auth: {auth_source}") - print(f" Region: {region}") - try: - import boto3 - sts = boto3.client("sts", region_name=region) - identity = sts.get_caller_identity() - arn = identity.get("Arn", "unknown") - print(f" Identity: {arn}") - except Exception: - print(" Identity: (could not resolve — boto3 STS call failed)") - print() - except ImportError: - pass # boto3 or bedrock_adapter not available - - # Show Azure Foundry Entra ID status - try: - from hermes_cli.config import load_config - _cfg = load_config() - _model_cfg = _cfg.get("model") if isinstance(_cfg, dict) else None - if isinstance(_model_cfg, dict): - _cfg_provider = str(_model_cfg.get("provider") or "").strip().lower() - _cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower() - if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id": - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - describe_active_credential, - has_azure_identity_installed, - ) - _base_url = str(_model_cfg.get("base_url") or "").strip() - _entra = _model_cfg.get("entra") or {} - if not isinstance(_entra, dict): - _entra = {} - _scope = ( - str(_entra.get("scope") or "").strip() - or SCOPE_AI_AZURE_DEFAULT - ) - print("azure-foundry (Microsoft Entra ID):") - print(f" Endpoint: {_base_url or '(not configured)'}") - print(f" Scope: {_scope}") - if not has_azure_identity_installed(): - print(" Status: ⚠ azure-identity not installed " - "(pip install azure-identity)") - else: - _entra_cfg = EntraIdentityConfig( - scope=_scope, - ) - _info = describe_active_credential(config=_entra_cfg, timeout_seconds=10.0) - _env_sources = _info.get("env_sources") or [] - if _info.get("ok"): - _tag = ", ".join(_env_sources) if _env_sources else "default chain" - print(f" Status: ✓ token acquired ({_tag})") - else: - _err = _info.get("error") or "credential chain exhausted" - print(f" Status: ⚠ {_err}") - _hint = _info.get("hint") - if _hint: - print(f" Hint: {_hint}") - print() - except Exception: - pass + _print_bedrock_status() + _print_azure_entra_status() print() # Main menu @@ -752,26 +681,23 @@ def _interactive_auth() -> None: if not raw or raw == str(len(choices)): return - if raw == "1": - _interactive_add() - elif raw == "2": - _interactive_remove() - elif raw == "3": - _interactive_reset() - elif raw == "4": - _interactive_strategy() + handler = { + "1": _interactive_add, + "2": _interactive_remove, + "3": _interactive_reset, + "4": _interactive_strategy, + }.get(raw) + if handler is not None: + handler() def _pick_provider(prompt: str = "Provider") -> str: """Prompt for a provider name with auto-complete hints.""" known = sorted(set(list(PROVIDER_REGISTRY.keys()) + ["openrouter"])) - custom_names = _get_custom_provider_names() - if custom_names: - custom_display = [name for name, _key, _provider_key in custom_names] - print(f"\nKnown providers: {', '.join(known)}") + custom_display = [entry["name"] for entry in _get_custom_provider_entries()] + print(f"\nKnown providers: {', '.join(known)}") + if custom_display: print(f"Custom endpoints: {', '.join(custom_display)}") - else: - print(f"\nKnown providers: {', '.join(known)}") try: raw = line_input(f"{prompt}: ").strip() except (EOFError, KeyboardInterrupt): @@ -786,6 +712,7 @@ def _interactive_add() -> None: raise SystemExit(f"Unknown provider: {provider}") # For OAuth-capable providers, ask which type + auth_type = "api_key" if provider in _OAUTH_CAPABLE_PROVIDERS: print(f"\n{provider} supports both API keys and OAuth login.") print(" 1. API key (paste a key from the provider dashboard)") @@ -796,18 +723,11 @@ def _interactive_add() -> None: return if type_choice == "2": auth_type = "oauth" - else: - auth_type = "api_key" - else: - auth_type = "api_key" - label = None try: - typed_label = line_input("Label / account name (optional): ").strip() + label = line_input("Label / account name (optional): ").strip() or None except (EOFError, KeyboardInterrupt): return - if typed_label: - label = typed_label auth_add_command(SimpleNamespace( provider=provider, auth_type=auth_type, label=label, api_key=None, @@ -839,27 +759,27 @@ def _interactive_remove() -> None: def _interactive_reset() -> None: - provider = _pick_provider("Provider to reset cooldowns for") + auth_reset_command(SimpleNamespace(provider=_pick_provider("Provider to reset cooldowns for"))) - auth_reset_command(SimpleNamespace(provider=provider)) + +_STRATEGY_DESCRIPTIONS = { + STRATEGY_FILL_FIRST: "Use first key until exhausted, then next", + STRATEGY_ROUND_ROBIN: "Cycle through keys evenly", + STRATEGY_LEAST_USED: "Always pick the least-used key", + STRATEGY_RANDOM: "Random selection", +} def _interactive_strategy() -> None: provider = _pick_provider("Provider to set strategy for") current = get_pool_strategy(provider) - strategies = [STRATEGY_FILL_FIRST, STRATEGY_ROUND_ROBIN, STRATEGY_LEAST_USED, STRATEGY_RANDOM] + strategies = list(_STRATEGY_DESCRIPTIONS) print(f"\nCurrent strategy for {provider}: {current}") print() - descriptions = { - STRATEGY_FILL_FIRST: "Use first key until exhausted, then next", - STRATEGY_ROUND_ROBIN: "Cycle through keys evenly", - STRATEGY_LEAST_USED: "Always pick the least-used key", - STRATEGY_RANDOM: "Random selection", - } for i, s in enumerate(strategies, 1): marker = " ←" if s == current else "" - print(f" {i}. {s:15s} — {descriptions.get(s, '')}{marker}") + print(f" {i}. {s:15s} — {_STRATEGY_DESCRIPTIONS[s]}{marker}") try: raw = input("\nStrategy [1-4]: ").strip() @@ -869,15 +789,14 @@ def _interactive_strategy() -> None: return try: - idx = int(raw) - 1 - strategy = strategies[idx] + strategy = strategies[int(raw) - 1] except (ValueError, IndexError): print("Invalid choice.") return from hermes_cli.config import load_config, save_config cfg = load_config() - pool_strategies = cfg.get("credential_pool_strategies") or {} + pool_strategies = cfg.get("credential_pool_strategies") if not isinstance(pool_strategies, dict): pool_strategies = {} pool_strategies[provider] = strategy @@ -886,28 +805,21 @@ def _interactive_strategy() -> None: print(f"Set {provider} strategy to: {strategy}") +_AUTH_ACTIONS = { + "add": auth_add_command, + "list": auth_list_command, + "remove": auth_remove_command, + "reset": auth_reset_command, + "status": auth_status_command, + "logout": auth_logout_command, + "spotify": auth_spotify_command, +} + + def auth_command(args) -> None: - action = getattr(args, "auth_action", "") - if action == "add": - auth_add_command(args) - return - if action == "list": - auth_list_command(args) - return - if action == "remove": - auth_remove_command(args) - return - if action == "reset": - auth_reset_command(args) - return - if action == "status": - auth_status_command(args) - return - if action == "logout": - auth_logout_command(args) - return - if action == "spotify": - auth_spotify_command(args) + handler = _AUTH_ACTIONS.get(getattr(args, "auth_action", "")) + if handler is not None: + handler(args) return # No subcommand — launch interactive mode _interactive_auth() diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 95955e6547..420a3ff26f 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -1,19 +1,7 @@ """GitHub Copilot authentication utilities. -Implements the OAuth device code flow used by the Copilot CLI and handles -token validation/exchange for the Copilot API. - -Token type support (per GitHub docs): - gho_ OAuth token ✓ (default via copilot login) - github_pat_ Fine-grained PAT ✓ (needs Copilot Requests permission) - ghu_ GitHub App token ✓ (via environment variable) - ghp_ Classic PAT ✗ NOT SUPPORTED - -Credential search order (matching Copilot CLI behaviour): - 1. COPILOT_GITHUB_TOKEN env var - 2. GH_TOKEN env var - 3. GITHUB_TOKEN env var - 4. gh auth token CLI fallback +Credential search order (matching Copilot CLI behaviour): 1. COPILOT_GITHUB_TOKEN env var 2. +GH_TOKEN env var 3. GITHUB_TOKEN env var 4. gh auth token CLI fallback """ from __future__ import annotations @@ -53,10 +41,7 @@ _DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds def validate_copilot_token(token: str) -> tuple[bool, str]: - """Validate that a token is usable with the Copilot API. - - Returns (valid, message). - """ + """Validate that a token is usable with the Copilot API.""" token = token.strip() if not token: return False, "Empty token" @@ -76,8 +61,8 @@ def validate_copilot_token(token: str) -> tuple[bool, str]: def resolve_copilot_token() -> tuple[str, str]: """Resolve a GitHub token suitable for Copilot API use. - Returns (token, source) where source describes where the token came from. - Raises ValueError if only a classic PAT is available. + Returns (token, source) where source describes where the token came from. Raises ValueError if + only a classic PAT is available. """ # 1. Check env vars in priority order any_env_var_set = False @@ -124,22 +109,14 @@ def resolve_copilot_token() -> tuple[str, str]: def _gh_cli_candidates() -> list[str]: """Return candidate ``gh`` binary paths, including common Homebrew installs.""" - candidates: list[str] = [] - - resolved = shutil.which("gh") - if resolved: - candidates.append(resolved) - + candidates: list[str] = [c for c in (shutil.which("gh"),) if c] for candidate in ( "/opt/homebrew/bin/gh", "/usr/local/bin/gh", str(Path.home() / ".local" / "bin" / "gh"), ): - if candidate in candidates: - continue - if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + if candidate not in candidates and os.path.isfile(candidate) and os.access(candidate, os.X_OK): candidates.append(candidate) - return candidates @@ -166,22 +143,15 @@ def _invalidate_gh_cli_token_cache() -> None: def _try_gh_cli_token() -> Optional[str]: """Return a token from ``gh auth token`` when the GitHub CLI is available. - When COPILOT_GH_HOST is set, passes ``--hostname`` so gh returns the - correct host's token. Also strips GITHUB_TOKEN / GH_TOKEN from the - subprocess environment so ``gh`` reads from its own credential store - (hosts.yml) instead of just echoing the env var back. - - The result (including a miss) is cached for a short TTL — see the cache - comment above. Callers that just re-authenticated can call - ``_invalidate_gh_cli_token_cache()`` to re-probe immediately. + The result (including a miss) is cached for a short TTL — see the cache comment above. Callers + that just re-authenticated can call ``_invalidate_gh_cli_token_cache()`` to re-probe + immediately. """ global _gh_cli_token_cache now = time.monotonic() - if _gh_cli_token_cache is not None: - cached_at, cached_token = _gh_cli_token_cache - if now - cached_at < _GH_CLI_TOKEN_CACHE_TTL_SECONDS: - return cached_token + if _gh_cli_token_cache is not None and now - _gh_cli_token_cache[0] < _GH_CLI_TOKEN_CACHE_TTL_SECONDS: + return _gh_cli_token_cache[1] token = _probe_gh_cli_token() _gh_cli_token_cache = (now, token) @@ -224,18 +194,17 @@ def _probe_gh_cli_token() -> Optional[str]: # ─── OAuth Device Code Flow ──────────────────────────────────────────────── +_DEVICE_CODE_TERMINAL_ERRORS = { + "expired_token": " ✗ Device code expired. Please try again.", + "access_denied": " ✗ Authorization was denied.", +} + def copilot_device_code_login( *, host: str = "github.com", timeout_seconds: float = 300, ) -> Optional[str]: - """Run the GitHub OAuth device code flow for Copilot. - - Prints instructions for the user, polls for completion, and returns - the OAuth access token on success, or None on failure/cancellation. - - This replicates the flow used by opencode and the Copilot CLI. - """ + """Run the GitHub OAuth device code flow for Copilot.""" import urllib.request import urllib.parse @@ -243,25 +212,24 @@ def copilot_device_code_login( device_code_url = f"https://{domain}/login/device/code" access_token_url = f"https://{domain}/login/oauth/access_token" + def _post_form(url: str, fields: dict, timeout: float) -> dict: + req = urllib.request.Request( + url, + data=urllib.parse.urlencode(fields).encode(), + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "HermesAgent/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + # Step 1: Request device code - data = urllib.parse.urlencode({ - "client_id": COPILOT_OAUTH_CLIENT_ID, - "scope": "read:user", - }).encode() - - req = urllib.request.Request( - device_code_url, - data=data, - headers={ - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "HermesAgent/1.0", - }, - ) - try: - with urllib.request.urlopen(req, timeout=15) as resp: - device_data = json.loads(resp.read().decode()) + device_data = _post_form( + device_code_url, {"client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user"}, 15 + ) except Exception as exc: logger.error("Failed to initiate device authorization: %s", exc) print(f" ✗ Failed to start device authorization: {exc}") @@ -289,25 +257,16 @@ def copilot_device_code_login( while time.monotonic() < deadline: time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) - poll_data = urllib.parse.urlencode({ - "client_id": COPILOT_OAUTH_CLIENT_ID, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - }).encode() - - poll_req = urllib.request.Request( - access_token_url, - data=poll_data, - headers={ - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "HermesAgent/1.0", - }, - ) - try: - with urllib.request.urlopen(poll_req, timeout=10) as resp: - result = json.loads(resp.read().decode()) + result = _post_form( + access_token_url, + { + "client_id": COPILOT_OAUTH_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + 10, + ) except Exception: print(".", end="", flush=True) continue @@ -317,29 +276,19 @@ def copilot_device_code_login( return result["access_token"] error = result.get("error", "") - if error == "authorization_pending": - print(".", end="", flush=True) - continue - elif error == "slow_down": + if error == "slow_down": # RFC 8628: add 5 seconds to polling interval server_interval = result.get("interval") if isinstance(server_interval, (int, float)) and server_interval > 0: interval = int(server_interval) else: interval += 5 + if error in ("authorization_pending", "slow_down"): print(".", end="", flush=True) continue - elif error == "expired_token": + if error: print() - print(" ✗ Device code expired. Please try again.") - return None - elif error == "access_denied": - print() - print(" ✗ Authorization was denied.") - return None - elif error: - print() - print(f" ✗ Authorization failed: {error}") + print(_DEVICE_CODE_TERMINAL_ERRORS.get(error, f" ✗ Authorization failed: {error}")) return None print() @@ -410,13 +359,14 @@ def _token_fingerprint(raw_token: str) -> str: def _read_jwt_store(path: Path) -> Optional[dict]: - """Bounded read of the on-disk JWT store → dict, or None if unusable. + """Bounded read of the on-disk JWT store → dict, or None if missing/unusable. - Single chokepoint for every read of the persisted store (load, eviction, - save-merge). A well-formed store is a few KB; a file over the 1 MiB cap or - with non-dict content is treated as unusable so a corrupt/oversized file - can't balloon memory or get rewritten back out. + Single chokepoint for every read of the persisted store (load, eviction, save-merge). A well- + formed store is a few KB; a file over the 1 MiB cap or with non-dict content is treated as + unusable so a corrupt/oversized file can't balloon memory or get rewritten back out. """ + if not path.exists(): + return None try: if path.stat().st_size > _JWT_DISK_MAX_BYTES: logger.debug( @@ -430,15 +380,23 @@ def _read_jwt_store(path: Path) -> Optional[dict]: return None +def _write_jwt_store(path: Path, store: dict) -> None: + """Atomically write the JWT store (tmp + os.replace), best-effort 0o600.""" + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(store), encoding="utf-8") + try: + os.chmod(tmp, 0o600) + except Exception: + pass + os.replace(tmp, path) + + def evict_cached_exchanged_token(raw_token: str) -> None: """Drop any cached exchanged JWT for ``raw_token`` (in-process + on-disk). - Used by the runtime stale-credential recovery path: when a live request - starts failing with a Copilot ``model_not_available_for_integrator`` / - ``model_not_supported`` 400, the cached exchanged token (or a degraded raw - fallback that was cached in its place) is stale. Evicting both cache tiers - forces the next ``exchange_copilot_token`` call to hit the network and mint - a fresh token instead of returning the poisoned cache entry. + Used by the runtime stale-credential recovery path: when a live request starts failing with a + Copilot ``model_not_available_for_integrator`` / ``model_not_supported`` 400, the cached + exchanged token (or a degraded raw fallback that was cached in its place) is stale. """ if not raw_token: return @@ -449,19 +407,13 @@ def evict_cached_exchanged_token(raw_token: str) -> None: # next exchange_copilot_token() must be allowed to hit the network. _exchange_failure_cache.pop(fp, None) path = _jwt_disk_path() - if not path or not path.exists(): + if not path: return try: store = _read_jwt_store(path) if store is not None and fp in store: del store[fp] - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(store), encoding="utf-8") - try: - os.chmod(tmp, 0o600) - except Exception: - pass - os.replace(tmp, path) + _write_jwt_store(path, store) except Exception as exc: logger.debug("Failed to evict cached Copilot JWT: %s", exc) @@ -476,23 +428,22 @@ def _jwt_disk_path() -> Optional[Path]: def _load_jwt_from_disk(fp: str) -> Optional[tuple[str, float, Optional[str]]]: - """Load a persisted exchanged JWT for ``fp`` → (api_token, expires_at, base_url).""" + """Load a persisted exchanged JWT for ``fp`` → (api_token, expires_at, base_url). + + An oversized/corrupt store is treated as unusable — the caller re-exchanges (bound shared with + eviction/save via _read_jwt_store). + """ path = _jwt_disk_path() - if not path or not path.exists(): + if not path: return None try: - # Bound the read: this file is a small JSON map of fingerprint → token. - # An oversized/corrupt store is treated as unusable — the caller - # re-exchanges (bound shared with eviction/save via _read_jwt_store). - store = _read_jwt_store(path) - entry = store.get(fp) if store is not None else None + entry = (_read_jwt_store(path) or {}).get(fp) if not isinstance(entry, dict): return None api_token = entry.get("api_token", "") expires_at = float(entry.get("expires_at", 0) or 0) - base_url = entry.get("base_url") if api_token and expires_at: - return api_token, expires_at, base_url + return api_token, expires_at, entry.get("base_url") except Exception as exc: logger.debug("Failed to load persisted Copilot JWT: %s", exc) return None @@ -506,13 +457,10 @@ def _save_jwt_to_disk( if not path: return try: - store: dict = {} - if path.exists(): - store = _read_jwt_store(path) or {} now = time.time() store = { k: v - for k, v in store.items() + for k, v in (_read_jwt_store(path) or {}).items() if isinstance(v, dict) and float(v.get("expires_at", 0) or 0) > now } store[fp] = { @@ -520,17 +468,7 @@ def _save_jwt_to_disk( "expires_at": expires_at, "base_url": base_url, } - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(store), encoding="utf-8") - try: - os.chmod(tmp, 0o600) - except Exception: - pass - os.replace(tmp, path) - try: - os.chmod(path, 0o600) - except Exception: - pass + _write_jwt_store(path, store) except Exception as exc: logger.debug("Failed to persist Copilot JWT: %s", exc) @@ -592,18 +530,10 @@ def _urlopen_bounded(req, timeout: float): def exchange_copilot_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]: """Exchange a raw GitHub token for a short-lived Copilot API token. - Calls ``GET https://api.github.com/copilot_internal/v2/token`` with - the raw GitHub token and returns ``(api_token, expires_at, base_url)``. - - The returned token is a semicolon-separated string (not a standard JWT) - used as ``Authorization: Bearer `` for Copilot API requests. - ``base_url`` is the account-specific API host: the authoritative - ``endpoints.api`` advertised by the exchange (enterprise/proxied - accounts), falling back to a host derived from the token's ``proxy-ep`` - field. Individual accounts have neither, so ``base_url`` is None. - - Results are cached in-process and reused until close to expiry. - Raises ``ValueError`` on failure. + The token is a semicolon-separated string (not a JWT) used as a Bearer token. ``base_url`` is + the account-specific host: the exchange's ``endpoints.api`` (enterprise/proxied accounts), else + derived from the token's ``proxy-ep``; individual accounts have neither, so it is None. Cached + in-process until close to expiry. Raises ``ValueError`` on failure. """ fp = _token_fingerprint(raw_token) @@ -621,25 +551,17 @@ def _exchange_copilot_token_locked( ) -> tuple[str, float, Optional[str]]: import urllib.request - # Re-check the caches under the lock: a concurrent caller may have just - # completed (or just failed) the exchange we were queued behind. - cached = _jwt_cache.get(fp) - if cached: - api_token, expires_at, base_url = cached - if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS: - return api_token, expires_at, base_url - - # Then the on-disk cache: a fresh process (e.g. gateway restart) has an - # empty in-process cache but may have a still-valid persisted JWT. Reusing - # it avoids a network round-trip at startup — precisely when the network is - # most likely to be flaky and the single-shot exchange would degrade to the - # raw token. - disk_cached = _load_jwt_from_disk(fp) - if disk_cached: - api_token, expires_at, base_url = disk_cached - if time.time() < expires_at - _JWT_REFRESH_MARGIN_SECONDS: - _jwt_cache[fp] = (api_token, expires_at, base_url) - return api_token, expires_at, base_url + # Re-check the in-process cache under the lock (a concurrent caller may have + # just completed the exchange we were queued behind), then the on-disk + # cache: a fresh process (e.g. gateway restart) has an empty in-process + # cache but may have a still-valid persisted JWT. Reusing it avoids a + # network round-trip at startup — precisely when the network is most likely + # to be flaky and the single-shot exchange would degrade to the raw token. + for lookup in (_jwt_cache.get, _load_jwt_from_disk): + cached = lookup(fp) + if cached and time.time() < cached[1] - _JWT_REFRESH_MARGIN_SECONDS: + _jwt_cache[fp] = cached + return cached # Negative cache: a recent exchange failure for this token means the # network round-trip (and its retry backoff) would just repeat. Fail @@ -673,7 +595,7 @@ def _exchange_copilot_token_locked( data = None last_exc: Optional[Exception] = None permanent_failure = False - for attempt in range(_EXCHANGE_MAX_ATTEMPTS): + for attempt in range(1, _EXCHANGE_MAX_ATTEMPTS + 1): try: with _urlopen_bounded(req, timeout) as resp: data = json.loads(resp.read().decode()) @@ -681,18 +603,15 @@ def _exchange_copilot_token_locked( except Exception as exc: # noqa: BLE001 — retry all, re-raise below last_exc = exc status = getattr(exc, "code", None) or getattr(exc, "status", None) - if status in _EXCHANGE_PERMANENT_HTTP_STATUSES: - permanent_failure = True - logger.debug( - "Copilot token exchange rejected (HTTP %s); not retrying", - status, - ) + permanent_failure = status in _EXCHANGE_PERMANENT_HTTP_STATUSES + if permanent_failure: + logger.debug("Copilot token exchange rejected (HTTP %s); not retrying", status) break - if attempt < _EXCHANGE_MAX_ATTEMPTS - 1: - sleep_s = _EXCHANGE_BACKOFF_BASE_SECONDS * (attempt + 1) + if attempt < _EXCHANGE_MAX_ATTEMPTS: + sleep_s = _EXCHANGE_BACKOFF_BASE_SECONDS * attempt logger.debug( "Copilot token exchange attempt %d/%d failed (%s); retrying in %.1fs", - attempt + 1, _EXCHANGE_MAX_ATTEMPTS, exc, sleep_s, + attempt, _EXCHANGE_MAX_ATTEMPTS, exc, sleep_s, ) time.sleep(sleep_s) if data is None: @@ -708,11 +627,9 @@ def _exchange_copilot_token_locked( _exchange_failure_cache.pop(fp, None) api_token = data.get("token", "") - expires_at = data.get("expires_at", 0) if not api_token: raise ValueError("Copilot token exchange returned empty token") - - # Convert expires_at to float if needed + expires_at = data.get("expires_at", 0) expires_at = float(expires_at) if expires_at else time.time() + 1800 # Resolve the account-specific API base URL. GitHub advertises the @@ -721,14 +638,10 @@ def _exchange_copilot_token_locked( # response omits it, fall back to deriving the host from the ``proxy-ep`` # field embedded in the exchanged token. Individual accounts have neither, # so ``base_url`` stays None and callers use the registry default. - base_url: Optional[str] = None endpoints = data.get("endpoints") - if isinstance(endpoints, dict): - api_endpoint = str(endpoints.get("api") or "").strip().rstrip("/") - if api_endpoint: - base_url = api_endpoint - if not base_url: - base_url = _derive_base_url_from_proxy_ep(api_token) + base_url: Optional[str] = ( + str(endpoints.get("api") or "").strip().rstrip("/") if isinstance(endpoints, dict) else "" + ) or _derive_base_url_from_proxy_ep(api_token) _jwt_cache[fp] = (api_token, expires_at, base_url) _save_jwt_to_disk(fp, api_token, expires_at, base_url) @@ -743,46 +656,33 @@ def _exchange_copilot_token_locked( def _derive_base_url_from_proxy_ep(token: str) -> Optional[str]: """Derive the Copilot API base URL from a proxy-ep field in the token. - The exchanged Copilot token is a semicolon-separated string like - ``tid=xxx;exp=xxx;proxy-ep=proxy.enterprise.githubcopilot.com;...``. - This extracts ``proxy-ep`` and converts it to an API base URL by - replacing the leading ``proxy.`` with ``api.``. - - Returns ``https://{api_hostname}`` or None if proxy-ep is absent. + The token looks like ``tid=…;exp=…;proxy-ep=proxy.enterprise.githubcopilot.com;…``; the API host + is that value with the leading ``proxy.`` replaced by ``api.``. None if proxy-ep is absent. """ import re m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token) if not m: return None - proxy_ep = m.group(1) # Strip scheme if present - for prefix in ("https://", "http://"): - if proxy_ep.startswith(prefix): - proxy_ep = proxy_ep[len(prefix):] - break - proxy_ep = proxy_ep.rstrip("/") + proxy_ep = re.sub(r"^https?://", "", m.group(1), count=1).rstrip("/") # Replace leading "proxy." with "api." if proxy_ep.startswith("proxy."): - api_host = "api." + proxy_ep[len("proxy."):] - else: - api_host = proxy_ep - - return f"https://{api_host}" + proxy_ep = "api." + proxy_ep[len("proxy."):] + return f"https://{proxy_ep}" def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]: """Exchange a raw GitHub token for a Copilot API token, with fallback. - Convenience wrapper: returns ``(api_token, base_url)`` on success, or - ``(raw_token, None)`` if the exchange fails (e.g. network error, unsupported - account type). This preserves existing behaviour for accounts that don't - need exchange while enabling access to internal-only models for those that do. + Convenience wrapper: returns ``(api_token, base_url)`` on success, or ``(raw_token, None)`` if + the exchange fails (e.g. network error, unsupported account type). This preserves existing + behaviour for accounts that don't need exchange while enabling access to internal-only models + for those that do. - ``base_url`` is the account-specific API endpoint advertised by the - exchange (``endpoints.api``, with a ``proxy-ep`` fallback), or None for - individual accounts. + ``base_url`` is the account-specific API endpoint advertised by the exchange (``endpoints.api``, + with a ``proxy-ep`` fallback), or None for individual accounts. """ if not raw_token: return raw_token, None @@ -801,12 +701,9 @@ def copilot_request_headers( is_agent_turn: bool = True, is_vision: bool = False, ) -> dict[str, str]: - """Build the standard headers for Copilot API requests. - - Replicates the header set used by opencode and the Copilot CLI. - """ + """Build the standard headers for Copilot API requests.""" headers: dict[str, str] = { - "Editor-Version": "vscode/1.104.1", + "Editor-Version": _EDITOR_VERSION, "User-Agent": "HermesAgent/1.0", "Copilot-Integration-Id": "vscode-chat", "Openai-Intent": "conversation-edits", diff --git a/hermes_cli/credential_lifecycle.py b/hermes_cli/credential_lifecycle.py index 9741fc30e8..6fecb79f40 100644 --- a/hermes_cli/credential_lifecycle.py +++ b/hermes_cli/credential_lifecycle.py @@ -1,40 +1,12 @@ """Unified provider-credential lifecycle across every store Hermes reads. -A provider API key can live in up to THREE stores at once: +* #51071 / #59761 — deleting a key removes it from ``.env`` but the stale ``credential_pool`` entry +(and ``provider_models_cache.json`` row) survives, so the provider keeps appearing in the model +picker, even across restarts (the pool loader is additive-only). - 1. ``~/.hermes/.env`` — the canonical secret store - 2. ``~/.hermes/auth.json`` → - ``credential_pool.[*]`` — env-seeded pool entries - (``source == "env:"``) persisted by the pool loader - 3. ``~/.hermes/config.yaml`` — inline mirrors written by the - custom-endpoint flows (``model.api_key``, ``auxiliary..api_key``, - ``custom_providers[*].api_key``) - -Historically the desktop/dashboard endpoints (PUT/DELETE ``/api/env``) and the -TUI-gateway RPCs only mutated store 1. That divergence is the root cause of a -whole bug family: - - * #51071 / #59761 — deleting a key removes it from ``.env`` but the stale - ``credential_pool`` entry (and ``provider_models_cache.json`` row) - survives, so the provider keeps appearing in the model picker, even - across restarts (the pool loader is additive-only). - * #62269 — updating a key rewrites ``.env`` but leaves the OLD key in a - higher-precedence ``config.yaml`` mirror (``model.api_key`` wins over - env at client construction), producing persistent 401s with a key the - UI no longer shows. - -This module is the single choke point: every surface that saves or removes a -provider credential should route through :func:`save_provider_env_credential` -/ :func:`remove_provider_env_credential` so all three stores stay consistent. - -OAuth preservation contract: removal only prunes credential-pool entries whose -``source`` is exactly ``env:``. OAuth/device-code/manual/borrowed entries -(``device_code``, ``manual*``, ``gh_cli``, ``claude_code``, ``oauth``, …) and -the ``providers.`` OAuth token blocks in auth.json are never touched — -deleting an API key must not revoke an OAuth grant for the same provider. - -Secrecy contract: no function in this module logs, prints, or returns a -credential value. Results carry key NAMES and config PATHS only. +This module is the single choke point: every surface that saves or removes a provider credential +should route through :func:`save_provider_env_credential` / :func:`remove_provider_env_credential` +so all three stores stay consistent. """ from __future__ import annotations @@ -64,16 +36,26 @@ def _providers_for_env_var(env_var: str) -> List[str]: return hits +def _for_each_provider(providers: List[str], import_path: str, *args: Any) -> None: + """Best-effort ``module.fn(provider, *args)`` for every provider; failures never propagate.""" + try: + import importlib + + module_name, fn_name = import_path.rsplit(".", 1) + fn = getattr(importlib.import_module(module_name), fn_name) + for provider in providers: + fn(provider, *args) + except Exception: + pass + + def _prune_env_pool_entries(env_var: str) -> List[str]: """Drop ``credential_pool`` entries seeded from ``env:``. - Operates across ALL providers in the pool (the source string names the - env var unambiguously, and shared vars like GITHUB_TOKEN may seed more - than one provider). Entries with any other source — OAuth, device-code, - manual, borrowed-CLI — are preserved verbatim, as are the - ``providers.`` OAuth blocks. - - Returns the list of provider ids that had entries pruned. + Operates across ALL providers: the source names the env var unambiguously and shared vars like + GITHUB_TOKEN may seed more than one provider. Entries with any other source (OAuth, + device-code, manual, borrowed-CLI) and ``providers.`` blocks are preserved verbatim. + Returns the provider ids that had entries pruned. """ from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store @@ -110,14 +92,13 @@ def _prune_env_pool_entries(env_var: str) -> List[str]: def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> List[str]: """Reconcile config.yaml api_key mirrors that hold ``old_value``. - Value-matched on purpose: we only touch a config entry when it provably - holds the SAME credential that just changed in ``.env`` — an independent - key the user configured for a different endpoint is left alone. + Value-matched on purpose: we only touch a config entry when it provably holds the SAME + credential that just changed in ``.env`` — an independent key the user configured for a + different endpoint is left alone. - ``new_value=None`` removes the mirror field; a string replaces it. - Operates on the RAW user config (never the defaults-merged view) so the - write doesn't bake defaults into the user's file. Returns the dotted - paths that were updated (names only — never values). + ``new_value=None`` removes the mirror field; a string replaces it. Operates on the RAW user + config (never the defaults-merged view) so the write doesn't bake defaults into the user's file. + Returns the dotted paths that were updated (names only — never values). """ if not old_value: return [] @@ -199,9 +180,9 @@ def purge_env_credential_references( ) -> Dict[str, Any]: """Remove non-.env references to an env-var credential. - Prunes ``credential_pool`` env-seeded entries and (optionally) the - affected providers' rows in ``provider_models_cache.json`` so the model - picker stops advertising a provider whose key is gone (#59761). + Prunes env-seeded pool entries and (optionally) the affected rows in + ``provider_models_cache.json`` so the model picker stops advertising a provider whose key is + gone. """ pruned = _prune_env_pool_entries(env_var) providers = sorted(set(pruned) | set(_providers_for_env_var(env_var))) @@ -209,43 +190,27 @@ def purge_env_credential_references( # lingering shell export (or another live process's os.environ) would # otherwise re-seed the pool entry on the next load_pool(). The matching # save path lifts the suppression on an explicit re-add. - try: - from hermes_cli.auth import suppress_credential_source - - for provider in providers: - suppress_credential_source(provider, f"env:{env_var}") - except Exception: - pass + _for_each_provider(providers, "hermes_cli.auth.suppress_credential_source", f"env:{env_var}") if clear_models_cache and providers: - try: - from hermes_cli.models import clear_provider_models_cache - - for provider in providers: - clear_provider_models_cache(provider) - except Exception: - # Cache cleanup is best-effort — a failure here must not block - # the credential removal itself. - pass + # Cache cleanup is best-effort — a failure here must not block + # the credential removal itself. + _for_each_provider(providers, "hermes_cli.models.clear_provider_models_cache") return {"pool_pruned": pruned, "providers": providers} def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]: """Save/update a credential in ``.env`` and reconcile every mirror. - After the ``.env`` write, any config.yaml mirror that held the PREVIOUS - value of this var (``model.api_key`` etc.) is updated to the new value so - a stale higher-precedence copy cannot shadow the rotation (#62269). - Suppressed ``env:`` pool sources are re-enabled so a deliberate - re-add through the UI behaves like ``hermes auth add``. + After the ``.env`` write, any config.yaml mirror that held the PREVIOUS value of this var + (``model.api_key`` etc.) is updated to the new value so a stale higher-precedence copy cannot + shadow the rotation (#62269). - The save also forces an immediate ``load_pool()`` for every provider - registered against this env var so the env-seeded ``credential_pool`` - entry is materialized to ``auth.json`` right now — the live runtime reads - from the pool, and before #96058 the Desktop "Save" action only touched - ``.env`` while ``auth.json``'s mtime stayed unchanged, so an OpenCode Go - (or any other env-backed provider) request kept 401'ing until the user - ran ``hermes auth add --type api-key`` separately. This makes - the Desktop save's effect on disk match what ``hermes auth add`` does. + The save also forces an immediate ``load_pool()`` for every provider registered against this env + var so the env-seeded ``credential_pool`` entry is materialized to ``auth.json`` right now — the + live runtime reads from the pool, and before #96058 the Desktop "Save" action only touched + ``.env`` while ``auth.json``'s mtime stayed unchanged, so an OpenCode Go (or any other env- + backed provider) request kept 401'ing until the user ran ``hermes auth add --type + api-key`` separately. """ from hermes_cli.config import load_env, save_env_value @@ -259,26 +224,15 @@ def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]: # A prior UI/CLI removal may have suppressed this env source; a fresh # save is an explicit re-add, so lift the suppression for every provider # that reads this var. - try: - from hermes_cli.auth import unsuppress_credential_source - - for provider in _providers_for_env_var(env_var): - unsuppress_credential_source(provider, f"env:{env_var}") - except Exception: - pass + providers = _providers_for_env_var(env_var) + _for_each_provider(providers, "hermes_cli.auth.unsuppress_credential_source", f"env:{env_var}") # Materialize the env-seeded credential_pool entry to auth.json NOW so the # next request authenticates against the just-saved key. ``load_pool`` is # idempotent and additive-only for env sources (#9331), so re-running it # is safe even when the pool already had this entry. Best-effort: a # failure here must not mask the successful .env write above. - try: - from agent.credential_pool import load_pool - - for provider in _providers_for_env_var(env_var): - load_pool(provider) - except Exception: - pass + _for_each_provider(providers, "agent.credential_pool.load_pool") return {"ok": True, "key": env_var, "config_updates": config_updates} @@ -286,14 +240,9 @@ def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]: def remove_provider_env_credential(env_var: str) -> Dict[str, Any]: """Remove a credential from EVERY store it lives in. - Clears the ``.env`` entry (and process env), prunes env-seeded - ``credential_pool`` entries, drops the affected providers' model-cache - rows, and removes any config.yaml mirror holding the same value. - OAuth/device-code/manual credentials are preserved (see module docstring). - - ``found`` is True when ANY store held the credential — callers that - previously 404'd on ".env miss" should key off this instead so a stale - pool-only entry can still be cleaned up through the same button. + Clears the ``.env`` entry (and process env), prunes env-seeded ``credential_pool`` entries, + drops the affected providers' model-cache rows, and removes any config.yaml mirror holding the + same value. OAuth/device-code/manual credentials are preserved (see module docstring). """ from hermes_cli.config import load_env, remove_env_value diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 3c397759d5..90f2906211 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -1,15 +1,4 @@ -""" -DingTalk Device Flow authorization. - -Implements the same 3-step registration flow as dingtalk-openclaw-connector: - 1. POST /app/registration/init → get nonce - 2. POST /app/registration/begin → get device_code + verification_uri_complete - 3. POST /app/registration/poll → poll until SUCCESS → get client_id + client_secret - -The verification_uri_complete is rendered as a QR code in the terminal so the -user can scan it with DingTalk to authorize, yielding AppKey + AppSecret -automatically. -""" +"""DingTalk Device Flow authorization.""" from __future__ import annotations @@ -58,11 +47,7 @@ def _api_post(path: str, payload: dict) -> dict: # ── Core flow ────────────────────────────────────────────────────────────── def begin_registration() -> dict: - """Start a device-flow registration. - - Returns a dict with keys: - device_code, verification_uri_complete, expires_in, interval - """ + """Start a device-flow registration.""" # Step 1: init → nonce init_data = _api_post("/app/registration/init", {"source": REGISTRATION_SOURCE}) nonce = str(init_data.get("nonce", "")).strip() @@ -87,20 +72,15 @@ def begin_registration() -> dict: def poll_registration(device_code: str) -> dict: - """Poll the registration status once. - - Returns a dict with keys: status, client_id?, client_secret?, fail_reason? - """ + """Poll the registration status once.""" data = _api_post("/app/registration/poll", {"device_code": device_code}) status_raw = str(data.get("status", "")).strip().upper() if status_raw not in {"WAITING", "SUCCESS", "FAIL", "EXPIRED"}: status_raw = "UNKNOWN" - return { - "status": status_raw, - "client_id": str(data.get("client_id", "")).strip() or None, - "client_secret": str(data.get("client_secret", "")).strip() or None, - "fail_reason": str(data.get("fail_reason", "")).strip() or None, - } + result = {"status": status_raw} + for key in ("client_id", "client_secret", "fail_reason"): + result[key] = str(data.get(key, "")).strip() or None + return result def wait_for_registration_success( @@ -109,10 +89,7 @@ def wait_for_registration_success( expires_in: int = 7200, on_waiting: Optional[callable] = None, ) -> Tuple[str, str]: - """Block until the registration succeeds or times out. - - Returns (client_id, client_secret). - """ + """Block until the registration succeeds or times out.""" deadline = time.monotonic() + expires_in retry_window = 120 # 2 minutes for transient errors retry_start = 0.0 @@ -176,10 +153,7 @@ def _ensure_qrcode_installed() -> bool: def render_qr_to_terminal(url: str) -> bool: - """Render *url* as a compact QR code in the terminal. - - Returns True if the QR code was printed, False if the library is missing. - """ + """Render *url* as a compact QR code in the terminal.""" try: import qrcode except ImportError: @@ -199,25 +173,14 @@ def render_qr_to_terminal(url: str) -> bool: rows = len(matrix) lines: list[str] = [] - TOP_HALF = "\u2580" # ▀ - BOTTOM_HALF = "\u2584" # ▄ - FULL_BLOCK = "\u2588" # █ - EMPTY = " " + # (top, bottom) -> ▀ ▄ █ or space + glyph = {(True, True): "\u2588", (True, False): "\u2580", + (False, True): "\u2584", (False, False): " "} for r in range(0, rows, 2): - line_chars: list[str] = [] - for c in range(len(matrix[r])): - top = matrix[r][c] - bottom = matrix[r + 1][c] if r + 1 < rows else False - if top and bottom: - line_chars.append(FULL_BLOCK) - elif top: - line_chars.append(TOP_HALF) - elif bottom: - line_chars.append(BOTTOM_HALF) - else: - line_chars.append(EMPTY) - lines.append(" " + "".join(line_chars)) + bottom_row = matrix[r + 1] if r + 1 < rows else [False] * len(matrix[r]) + lines.append(" " + "".join( + glyph[(bool(top), bool(bottom))] for top, bottom in zip(matrix[r], bottom_row))) print("\n".join(lines)) return True @@ -226,11 +189,7 @@ def render_qr_to_terminal(url: str) -> bool: # ── High-level entry point for the setup wizard ─────────────────────────── def dingtalk_qr_auth() -> Optional[Tuple[str, str]]: - """Run the interactive QR-code device-flow authorization. - - Returns (client_id, client_secret) on success, or None if the user - cancelled or the flow failed. - """ + """Run the interactive QR-code device-flow authorization.""" from hermes_cli.setup import print_info, print_success, print_warning, print_error print() diff --git a/hermes_cli/gateway_enroll.py b/hermes_cli/gateway_enroll.py index 9f529e625c..975521c838 100644 --- a/hermes_cli/gateway_enroll.py +++ b/hermes_cli/gateway_enroll.py @@ -1,31 +1,11 @@ """``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector. -The connector⇄gateway channel is authenticated (the gateway may be -customer-managed and internet-exposed). This command is the gateway half of the -zero-touch enrollment in the connector repo's -``docs/connector-gateway-auth-design.md``: +Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the secret directly and +stamps it into the container env, so this command refuses to run under ``is_managed()`` (mirrors +``dashboard register``). - 1. Resolve a fresh Nous Portal access token from the existing login - (``~/.hermes/auth.json``) — the same path ``hermes dashboard register`` - uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)* - the caller owns; the connector derives the authoritative tenant from it via - ``GET /api/oauth/account`` (never from anything the gateway asserts). - 2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll`` - with that token in the ``Authorization`` header, over TLS. - 3. The connector verifies the enrollment token (signature + single-use + - tenant match), mints a per-gateway secret, get-or-creates the per-tenant - delivery key, and returns both ONCE. - 4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / - ``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into - ``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade; - the per-tenant delivery key verifies signed inbound deliveries. - -Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the -secret directly and stamps it into the container env, so this command refuses to -run under ``is_managed()`` (mirrors ``dashboard register``). - -EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until -≥2 Class-1 platforms validate the contract. +EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until ≥2 Class-1 +platforms validate the contract. """ from __future__ import annotations @@ -35,7 +15,6 @@ import os import socket import sys import urllib.error -import urllib.parse import urllib.request from pathlib import Path from typing import Optional @@ -44,11 +23,9 @@ from typing import Optional def _default_gateway_id() -> str: """A stable-ish default gateway instance id: ``-``. - The gatewayId identifies this enrolled instance for kill-switch granularity - (the connector indexes its secret verify list by it). Default to the host - name so a human can recognize it; overridable via ``--gateway-id``. + The gatewayId gives kill-switch granularity (the connector indexes its secret verify list by + it), so default to the host name for recognizability; override via ``--gateway-id``. """ - host = "" try: host = socket.gethostname().strip() except Exception: @@ -60,9 +37,9 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]: """Resolve the connector base URL (no trailing slash) for enrollment. Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env > - ``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial - target; enrollment is an ``http(s)://`` POST to the same host, so we map the - scheme. Returns None when nothing is configured (the user must supply one). + ``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial target; enrollment is + an ``http(s)://`` POST to the same host, so we map the scheme. Returns None when nothing is + configured (the user must supply one). """ raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip() if not raw: @@ -75,32 +52,28 @@ def _resolve_connector_url(override: Optional[str]) -> Optional[str]: raw = "" if not raw: return None - raw = raw.rstrip("/") # The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll. - if raw.startswith("ws://"): - raw = "http://" + raw[len("ws://"):] - elif raw.startswith("wss://"): - raw = "https://" + raw[len("wss://"):] # Strip a trailing /relay path segment if the user pasted the dial URL. - if raw.endswith("/relay"): - raw = raw[: -len("/relay")] - return raw + for ws_scheme, http_scheme in (("ws://", "http://"), ("wss://", "https://")): + if raw.startswith(ws_scheme): + raw = http_scheme + raw[len(ws_scheme):] + break + return raw.rstrip("/").removesuffix("/relay") def _resolve_identity_token() -> str: """Resolve the caller-identity bearer token (generic-OIDC or Nous Portal). - Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and - the runtime self-provision path share ONE implementation (generic OAuth2 - client-credentials when ``gateway.idp.token_url`` is set — the air-gapped / - self-hosted-IdP path; otherwise Nous Portal). Raises RuntimeError on failure. + Delegates to the canonical resolver in ``gateway.relay`` so the enroll CLI and the runtime self- + provision path share ONE implementation (generic OAuth2 client-credentials when + ``gateway.idp.token_url`` is set — the air-gapped / self-hosted-IdP path; otherwise Nous + Portal). Raises RuntimeError on failure. """ from gateway.relay import _resolve_relay_identity_token return _resolve_relay_identity_token() - def _post_enroll( *, connector_base_url: str, @@ -111,9 +84,8 @@ def _post_enroll( ) -> dict: """POST to the connector's ``/relay/enroll`` and return the JSON body. - Raises RuntimeError with a user-facing message on any non-2xx / transport - failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}`` - on success, ``{error}`` at 400/401/403. + Raises RuntimeError with a user-facing message on any non-2xx / transport failure. Success + returns ``{secret, deliveryKey, tenant, gatewayId}``; 400/401/403 return ``{error}``. """ url = f"{connector_base_url.rstrip('/')}/relay/enroll" data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8") @@ -137,18 +109,15 @@ def _post_enroll( except Exception: pass if exc.code == 401: - raise RuntimeError( + message = ( "Connector rejected the caller identity (401). Your Nous Portal " "token could not be verified — try `hermes auth add nous` and retry." - ) from exc - if exc.code == 403: - raise RuntimeError( - detail - or "Enrollment token invalid, expired, already used, or tenant mismatch (403)." - ) from exc - raise RuntimeError( - f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "") - ) from exc + ) + elif exc.code == 403: + message = detail or "Enrollment token invalid, expired, already used, or tenant mismatch (403)." + else: + message = f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "") + raise RuntimeError(message) from exc except urllib.error.URLError as exc: raise RuntimeError( f"Could not reach the connector at {connector_base_url}: {exc.reason}" @@ -159,6 +128,12 @@ def _post_enroll( return payload +def _fail(*lines: str) -> None: + for line in lines: + print(line) + sys.exit(1) + + def cmd_gateway_enroll(args) -> None: """Enroll this gateway with a relay connector; persist the auth creds to .env.""" from hermes_cli.auth import AuthError @@ -169,29 +144,26 @@ def cmd_gateway_enroll(args) -> None: # from inside such a container is a mistake — and save_env_value refuses to # write anyway. if is_managed(): - print( + _fail( "✗ `hermes gateway enroll` is not available in a managed/hosted install.\n" " The relay gateway secret is provisioned by the hosting platform." ) - sys.exit(1) enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip() if not enrollment_token: - print( + _fail( "✗ No enrollment token. Pass --token (or set " "GATEWAY_RELAY_ENROLL_TOKEN).\n" " The connector mints this single-use token when your tenant's route " "is provisioned; it is delivered with your gateway config." ) - sys.exit(1) connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None)) if not connector_base_url: - print( + _fail( "✗ No connector URL. Pass --connector-url (or set GATEWAY_RELAY_URL " "/ gateway.relay_url in config.yaml)." ) - sys.exit(1) gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip() @@ -202,14 +174,13 @@ def cmd_gateway_enroll(args) -> None: access_token = _resolve_identity_token() except AuthError as exc: if getattr(exc, "relogin_required", False): - print("✗ You're not logged into Nous Portal.") - print(" Run `hermes setup` (or `hermes auth add nous`) first, then retry.") - else: - print(f"✗ Could not resolve a Nous Portal access token: {exc}") - sys.exit(1) + _fail( + "✗ You're not logged into Nous Portal.", + " Run `hermes setup` (or `hermes auth add nous`) first, then retry.", + ) + _fail(f"✗ Could not resolve a Nous Portal access token: {exc}") except Exception as exc: - print(f"✗ Could not resolve a caller-identity token: {exc}") - sys.exit(1) + _fail(f"✗ Could not resolve a caller-identity token: {exc}") # 2-3. Redeem the enrollment token at the connector. try: @@ -220,11 +191,8 @@ def cmd_gateway_enroll(args) -> None: gateway_id=gateway_id, ) except RuntimeError as exc: - print(f"✗ Enrollment failed: {exc}") - sys.exit(1) + _fail(f"✗ Enrollment failed: {exc}") - secret = str(result.get("secret") or "") - delivery_key = str(result.get("deliveryKey") or "") tenant = str(result.get("tenant") or "") resolved_gateway_id = str(result.get("gatewayId") or gateway_id) @@ -232,22 +200,20 @@ def cmd_gateway_enroll(args) -> None: # save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs. to_write = { "GATEWAY_RELAY_ID": resolved_gateway_id, - "GATEWAY_RELAY_SECRET": secret, - "GATEWAY_RELAY_DELIVERY_KEY": delivery_key, + "GATEWAY_RELAY_SECRET": str(result.get("secret") or ""), + "GATEWAY_RELAY_DELIVERY_KEY": str(result.get("deliveryKey") or ""), } # Persist the connector URL too (as the ws(s):// dial target) when supplied - # explicitly, so the runtime can dial without re-specifying it. - explicit_url = (getattr(args, "connector_url", None) or "").strip() - if explicit_url: - to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/") - - # Phase 5 §5.2: persist the wake URL so self_provision_relay forwards it to - # the connector (which pokes it to wake this gateway when buffered work - # arrives while it's idle). Optional — omitted ⇒ the connector can't wake it, - # but the gateway still drains on its next reconnect. - explicit_wake_url = (getattr(args, "wake_url", None) or "").strip() - if explicit_wake_url: - to_write["GATEWAY_RELAY_WAKE_URL"] = explicit_wake_url.rstrip("/") + # explicitly, so the runtime can dial without re-specifying it. Phase 5 + # §5.2: likewise the wake URL, so self_provision_relay forwards it to the + # connector (which pokes it to wake this gateway when buffered work arrives + # while it's idle). Optional — omitted ⇒ the connector can't wake it, but + # the gateway still drains on its next reconnect. + explicit_urls = { + env_key: (getattr(args, arg, None) or "").strip() + for arg, env_key in (("connector_url", "GATEWAY_RELAY_URL"), ("wake_url", "GATEWAY_RELAY_WAKE_URL")) + } + to_write.update({k: v.rstrip("/") for k, v in explicit_urls.items() if v}) for key, value in to_write.items(): if not value: @@ -255,21 +221,16 @@ def cmd_gateway_enroll(args) -> None: try: save_env_value(key, value) except Exception as exc: - print(f"✗ Failed to write {key} to .env: {exc}") - sys.exit(1) + _fail(f"✗ Failed to write {key} to .env: {exc}") from hermes_cli.config import get_env_path print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else "")) print() print(f" Wrote to {get_env_path()}:") - print(f" GATEWAY_RELAY_ID={resolved_gateway_id}") - print(" GATEWAY_RELAY_SECRET=") - print(" GATEWAY_RELAY_DELIVERY_KEY=") - if explicit_url: - print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}") - if explicit_wake_url: - print(f" GATEWAY_RELAY_WAKE_URL={explicit_wake_url.rstrip('/')}") + for key, value in to_write.items(): + shown = "" if key in ("GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY") else value + print(f" {key}={shown}") print() # GATEWAY_RELAY_URL / GATEWAY_RELAY_WAKE_URL are process-global deployment # stamps (agent/secret_scope.py): a multiplexed gateway resolves them from @@ -280,10 +241,7 @@ def cmd_gateway_enroll(args) -> None: # warn rather than refuse — but don't let a secondary-profile enroll claim # a config that will silently never activate. Emitted BEFORE the generic # restart line so the two don't contradict each other. - warned_secondary = False - if explicit_url or explicit_wake_url: - warned_secondary = _warn_if_secondary_multiplex_profile() - if not warned_secondary: + if not (any(explicit_urls.values()) and _warn_if_secondary_multiplex_profile()): print( " The gateway now authenticates its relay WS upgrade with the per-gateway\n" " secret and verifies signed inbound deliveries with the tenant delivery\n" @@ -292,17 +250,9 @@ def cmd_gateway_enroll(args) -> None: def _warn_if_secondary_multiplex_profile() -> bool: - """Warn when relay routing stamps were written to a secondary profile's - .env that a multiplexed gateway will never read them from. Returns True - when the warning fired (the caller suppresses the generic restart text). - - The topology decision is owned by the DEFAULT root, not the active - profile home: ``multiplex_profiles`` normally lives in - ``/config.yaml`` (or the GATEWAY_MULTIPLEX_PROFILES env - override), and the secondary check is the resolved-path relationship to - ``/profiles/`` — mirroring the multiplexer-conflict guard - in hermes_cli/gateway.py. Best-effort: any failure to determine the - topology stays silent (the credential write itself succeeded). + """Warn when relay routing stamps were written to a secondary profile's .env that a multiplexed + gateway will never read them from. Returns True when the warning fired (the caller suppresses + the generic restart text). """ try: from hermes_constants import get_default_hermes_root diff --git a/hermes_cli/memory_oauth.py b/hermes_cli/memory_oauth.py index 34ee3e8c70..d931605765 100644 --- a/hermes_cli/memory_oauth.py +++ b/hermes_cli/memory_oauth.py @@ -1,11 +1,4 @@ -"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``. - -Kept out of ``web_server.py`` so the memory feature's surface stays in the -memory layer. Dispatch is by convention: a provider's flow lives at -``plugins.memory..oauth_flow`` exposing ``start_loopback_flow_background`` -and ``get_flow_status``; a provider without that module simply 404s. No provider -is named here. -""" +"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``.""" from __future__ import annotations diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index 30247ff661..b0da87ad4d 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -7,7 +7,7 @@ import json import threading import time import urllib.request -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from datetime import datetime, timezone from typing import Any, Literal, Optional @@ -68,9 +68,9 @@ class NousPaidServiceAccessInfo: class NousToolAccessInfo: """Free tool-pool entitlement, decoupled from paid/billing access. - Mirrors the Portal's ``tool_access`` claim/field: ``enabled`` is true when a - positive tool-pool balance is live and not gated off; ``coverage`` maps each - tool category to whether the pool funds it (FAL video is excluded). + Mirrors the Portal's ``tool_access`` field: ``enabled`` is true when a positive pool balance is + live and not gated off; ``coverage`` maps each tool category to whether the pool funds it (FAL + video is excluded). """ enabled: bool = False @@ -117,14 +117,12 @@ class NousPortalAccountInfo: """Coarse "entitled to any managed tool" gate: paid access OR a live free tool pool. Use :meth:`tool_gateway_entitled_for` to gate a specific tool category (the pool does not cover every category).""" - if self.paid_service_access is True: - return True - return self.tool_access is not None and self.tool_access.enabled + return self.paid_service_access is True or bool(self.tool_access and self.tool_access.enabled) def tool_gateway_entitled_for(self, category: str) -> bool: - """Whether a specific tool category is entitled. Paid users are entitled - everywhere; free tool-pool users only where ``coverage[category]`` is - true (e.g. image but not video).""" + """Whether a specific tool category is entitled. Paid users are entitled everywhere; free + tool-pool users only where ``coverage[category]`` is true (e.g. image but not video). + """ if self.paid_service_access is True: return True ta = self.tool_access @@ -138,9 +136,7 @@ def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None except Exception: DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" - base = None - if account_info is not None: - base = account_info.portal_base_url + base = account_info.portal_base_url if account_info is not None else None if not isinstance(base, str) or not base.strip(): base = DEFAULT_NOUS_PORTAL_URL return f"{base.rstrip('/')}/billing" @@ -149,14 +145,10 @@ def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None) -> str: """Return the portal top-up URL that auto-opens the top-up modal. - Prefers the org-pinned page ``{base}/orgs/{slug}/billing?topup=open`` (skips - the legacy shim's re-resolution + multi-org disambiguation). Falls back to the - legacy ``{base}/billing?topup=open`` when the account has no ``org_slug`` (the - portal's ``slug`` is nullable; the legacy page forwards the param through to - the org-pinned page). Never builds ``/orgs/None/billing``. - - The ``?topup=open`` query is the NAS enabler that lands the user in the - top-up flow rather than just on the billing page. + Prefers the org-pinned ``{base}/orgs/{slug}/billing?topup=open`` (skips the legacy shim's + re-resolution and multi-org disambiguation); falls back to ``{base}/billing?topup=open`` when + ``org_slug`` is null, never ``/orgs/None/billing``. ``?topup=open`` is what lands the user in + the top-up flow rather than just the billing page. """ base_billing = nous_portal_billing_url(account_info) # {base}/billing base = base_billing[: -len("/billing")] # strip the trailing /billing @@ -178,17 +170,11 @@ def format_nous_portal_entitlement_message( ) -> Optional[str]: """Return user-facing guidance for a missing Nous tool-gateway entitlement. - ``None`` means the account is entitled to use the capability — via paid - service access OR a live free tool pool that covers it. The message works - from normalized entitlement fields rather than subscription price alone: - purchased credits without a subscription still count as paid access, while a - paid subscription with exhausted usable credits does not. - - ``coverage_category`` scopes the check to a single tool category (e.g. - ``"fal-video"``). When given, a user who is entitled overall but whose - access does not fund that category gets a neutral billing nudge instead of a - message implying their credits are exhausted. The pool-vs-paid distinction is - never surfaced to the user. + ``None`` means entitled via paid access OR a live free pool that covers it. Uses normalized + entitlement fields, not price: purchased credits without a subscription count as paid, a paid + subscription with exhausted credits does not. ``coverage_category`` scopes the check to one + category; an otherwise-entitled user whose access doesn't fund it gets a neutral billing nudge + rather than an "exhausted" message. The pool-vs-paid distinction is never surfaced. """ billing_url = nous_portal_billing_url(account_info) @@ -265,17 +251,18 @@ def _no_paid_access_message( capability: str, billing_url: str, ) -> str: - access = account_info.paid_service_access_info - has_active_subscription = access.has_active_subscription if access else None - active_subscription_is_paid = access.active_subscription_is_paid if access else None - total_usable = access.total_usable_credits if access else None - subscription_credits = access.subscription_credits_remaining if access else None - purchased_credits = access.purchased_credits_remaining if access else None + access = account_info.paid_service_access_info or NousPaidServiceAccessInfo() + has_active_subscription = access.has_active_subscription + active_subscription_is_paid = access.active_subscription_is_paid + credit_detail = _credit_detail( + access.total_usable_credits, + access.subscription_credits_remaining, + access.purchased_credits_remaining, + ) - if access and access.member_spend_cap_exceeded: + if access.member_spend_cap_exceeded: cap = access.member_spend_cap_usd spent = access.member_spend_usd - credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits) cap_detail = "" if cap is not None and spent is not None: cap_detail = f" Your organisation's per-member spend cap is ${cap:.2f} and you've spent ${spent:.2f} of it." @@ -290,7 +277,6 @@ def _no_paid_access_message( ) if has_active_subscription and active_subscription_is_paid: - credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits) return ( f"Your Nous Portal credits are exhausted{credit_detail}, so {capability} " f"is unavailable. Top up or renew credits at {billing_url}." @@ -303,14 +289,12 @@ def _no_paid_access_message( ) if has_active_subscription is False: - credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits) return ( f"Your Nous Portal account has no active subscription or usable credits" f"{credit_detail}, so {capability} is unavailable. Subscribe or add credits " f"at {billing_url}." ) - credit_detail = _credit_detail(total_usable, subscription_credits, purchased_credits) return ( f"Your Nous Portal account has no usable paid credits{credit_detail}, so " f"{capability} is unavailable. Add credits or update billing at {billing_url}." @@ -322,16 +306,9 @@ def _credit_detail( subscription_credits: Optional[float], purchased_credits: Optional[float], ) -> str: - parts: list[str] = [] - if total_usable is not None: - parts.append(f"usable ${total_usable:.2f}") - if subscription_credits is not None: - parts.append(f"subscription ${subscription_credits:.2f}") - if purchased_credits is not None: - parts.append(f"purchased ${purchased_credits:.2f}") - if not parts: - return "" - return f" ({', '.join(parts)})" + labelled = (("usable", total_usable), ("subscription", subscription_credits), ("purchased", purchased_credits)) + parts = [f"{label} ${amount:.2f}" for label, amount in labelled if amount is not None] + return f" ({', '.join(parts)})" if parts else "" def reset_nous_portal_account_info_cache() -> None: @@ -347,10 +324,9 @@ def get_nous_portal_account_info( ) -> NousPortalAccountInfo: """Return normalized Nous Portal account entitlement information. - By default, a valid unexpired OAuth access JWT is used as a low-latency - local account snapshot. ``force_fresh=True`` always calls - ``/api/oauth/account`` and bypasses the short-lived cache. JWT claims are - decoded locally for UX gating only; server APIs remain authoritative. + By default, a valid unexpired OAuth access JWT is used as a low-latency local account snapshot. + ``force_fresh=True`` always calls ``/api/oauth/account`` and bypasses the short-lived cache. JWT + claims are decoded locally for UX gating only; server APIs remain authoritative. """ try: from hermes_cli.auth import get_provider_auth_state @@ -362,21 +338,14 @@ def get_nous_portal_account_info( access_token = state.get("access_token") portal_base_url = _portal_base_url(state) if not isinstance(access_token, str) or not access_token.strip(): - pool_oauth_info = _info_from_oauth_pool( - force_fresh=force_fresh, - min_jwt_ttl_seconds=min_jwt_ttl_seconds, - portal_base_url=portal_base_url, - ) - if pool_oauth_info is not None: - return pool_oauth_info - pool_info = _info_from_inference_key_pool(portal_base_url) - if pool_info is not None: - return pool_info - return NousPortalAccountInfo( - logged_in=False, - source="none", - fresh=False, - portal_base_url=portal_base_url, + return ( + _info_from_oauth_pool( + force_fresh=force_fresh, + min_jwt_ttl_seconds=min_jwt_ttl_seconds, + portal_base_url=portal_base_url, + ) + or _info_from_inference_key_pool(portal_base_url) + or NousPortalAccountInfo(logged_in=False, source="none", fresh=False, portal_base_url=portal_base_url) ) if not force_fresh: @@ -399,12 +368,8 @@ def get_nous_portal_account_info( def nous_policy_present() -> Optional[bool]: """Whether the caller's org carries a restrictive model/provider policy. - Reads the ``policy_present`` claim off the access token, so it costs no - request; ``/api/oauth/account`` does not carry it. Stamped at mint time, so - it goes stale until the next token refresh. - - ``None`` is unknown — an older mint or an unreadable claim — and must not be - reported as the absence of a policy. + ``None`` is unknown — an older mint or an unreadable claim — and must not be reported as the + absence of a policy. """ try: from hermes_cli.auth import get_provider_auth_state, _decode_jwt_claims @@ -424,13 +389,10 @@ def nous_policy_present() -> Optional[bool]: def nous_policy_notice(*, removed: bool) -> str: """A one-line notice for a list the org's policy narrowed, else ``""``. - A blocked model is omitted rather than marked, which reads as "Hermes does - not support this". This says which it is without enumerating the blocked - set, which under an allowlist is most of the catalog. - - *removed* is whether the filter actually dropped anything. The catalog read - fails open — an anonymous or empty one narrows nothing — so the claim alone - would label a full list as filtered. + A blocked model is omitted rather than marked, which reads as "Hermes does not support this"; + this says which it is without enumerating the blocked set. ``removed`` must reflect whether the + filter actually dropped anything — the catalog read fails open, so the claim alone would label a + full list as filtered. """ if not removed or nous_policy_present() is not True: return "" @@ -462,28 +424,10 @@ def _fresh_account_info( if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL: return cached_info - payload = _fetch_nous_account_info(access_token, portal_base_url) - if not payload: - return _error_info( - error="empty_account_response", - logged_in=True, - portal_base_url=portal_base_url, - ) - if isinstance(payload.get("error"), str): - return _error_info( - error=payload.get("error") or "account_response_error", - logged_in=True, - portal_base_url=portal_base_url, - raw_account=payload, - ) - - info = _info_from_account_payload( - payload, - state=refreshed_state, - portal_base_url=portal_base_url, - ) - with _ACCOUNT_INFO_CACHE_LOCK: - _account_info_cache = (cache_key, time.monotonic(), info) + info = _info_from_fetched_account(access_token, state=refreshed_state, portal_base_url=portal_base_url) + if info.source != "error": + with _ACCOUNT_INFO_CACHE_LOCK: + _account_info_cache = (cache_key, time.monotonic(), info) return info except Exception as exc: return _error_info( @@ -509,15 +453,8 @@ def _info_from_inference_key_pool( logged_in=False, source="inference_key", fresh=False, - portal_base_url=( - getattr(entry, "portal_base_url", None) - or portal_base_url - ), - inference_base_url=( - getattr(entry, "inference_base_url", None) - or getattr(entry, "runtime_base_url", None) - or getattr(entry, "base_url", None) - ), + portal_base_url=getattr(entry, "portal_base_url", None) or portal_base_url, + inference_base_url=_pool_entry_inference_url(entry), inference_credential_present=True, credential_source=f"pool:{getattr(entry, 'label', 'unknown')}", error="portal_oauth_missing", @@ -543,18 +480,11 @@ def _info_from_oauth_pool( if not isinstance(access_token, str) or not access_token.strip(): return None - entry_portal_url = ( - getattr(entry, "portal_base_url", None) - or portal_base_url - ) + entry_portal_url = getattr(entry, "portal_base_url", None) or portal_base_url state = { "access_token": access_token, "client_id": getattr(entry, "client_id", None), - "inference_base_url": ( - getattr(entry, "inference_base_url", None) - or getattr(entry, "runtime_base_url", None) - or getattr(entry, "base_url", None) - ), + "inference_base_url": _pool_entry_inference_url(entry), "agent_key": getattr(entry, "agent_key", None), "credential_source": f"pool:{getattr(entry, 'label', 'unknown')}", } @@ -570,30 +500,36 @@ def _info_from_oauth_pool( return jwt_info try: - payload = _fetch_nous_account_info(access_token, entry_portal_url) + return _info_from_fetched_account(access_token, state=state, portal_base_url=entry_portal_url) except Exception as exc: - return _error_info( - error=exc, - logged_in=True, - portal_base_url=entry_portal_url, - ) + return _error_info(error=exc, logged_in=True, portal_base_url=entry_portal_url) + + +def _info_from_fetched_account( + access_token: str, + *, + state: dict[str, Any], + portal_base_url: Optional[str], +) -> NousPortalAccountInfo: + """Call ``/api/oauth/account`` and normalize; empty or ``error`` payloads become error infos.""" + payload = _fetch_nous_account_info(access_token, portal_base_url) if not payload: - return _error_info( - error="empty_account_response", - logged_in=True, - portal_base_url=entry_portal_url, - ) + return _error_info(error="empty_account_response", logged_in=True, portal_base_url=portal_base_url) if isinstance(payload.get("error"), str): return _error_info( error=payload.get("error") or "account_response_error", logged_in=True, - portal_base_url=entry_portal_url, + portal_base_url=portal_base_url, raw_account=payload, ) - return _info_from_account_payload( - payload, - state=state, - portal_base_url=entry_portal_url, + return _info_from_account_payload(payload, state=state, portal_base_url=portal_base_url) + + +def _pool_entry_inference_url(entry: Any) -> Optional[str]: + return ( + getattr(entry, "inference_base_url", None) + or getattr(entry, "runtime_base_url", None) + or getattr(entry, "base_url", None) ) @@ -730,10 +666,10 @@ def _info_from_account_payload( def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]: - """Parse a Portal ``tool_access`` object (from the JWT claim or the account - API) into :class:`NousToolAccessInfo`. Fails closed: a non-object value - yields ``None``, and only literal ``true`` counts for ``enabled`` and each - coverage entry.""" + """Parse a Portal ``tool_access`` object (from the JWT claim or the account API) into + :class:`NousToolAccessInfo`. Fails closed: a non-object value yields ``None``, and only literal + ``true`` counts for ``enabled`` and each coverage entry. + """ if not isinstance(value, dict): return None enabled = _coerce_bool(value.get("enabled")) is True @@ -746,43 +682,22 @@ def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]: return NousToolAccessInfo(enabled=enabled, coverage=coverage) -def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]: +def _coerced_dataclass(cls, value: Any): + """Build ``cls`` from a payload dict, coercing each field by its declared Optional type. + + Field names double as payload keys; unknown payload keys are ignored. Non-dict input -> None. + """ if not isinstance(value, dict): return None - return NousPortalSubscriptionInfo( - plan=_coerce_str(value.get("plan")), - tier=_coerce_int(value.get("tier")), - monthly_charge=_coerce_float(value.get("monthly_charge")), - monthly_credits=_coerce_float(value.get("monthly_credits")), - current_period_end=_coerce_str(value.get("current_period_end")), - credits_remaining=_coerce_float(value.get("credits_remaining")), - rollover_credits=_coerce_float(value.get("rollover_credits")), - ) + return cls(**{f.name: _COERCERS[f.type](value.get(f.name)) for f in fields(cls)}) + + +def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]: + return _coerced_dataclass(NousPortalSubscriptionInfo, value) def _paid_service_access_from_payload(value: Any) -> Optional[NousPaidServiceAccessInfo]: - if not isinstance(value, dict): - return None - allowed = _coerce_bool(value.get("allowed")) - paid_access = _coerce_bool(value.get("paid_access")) - return NousPaidServiceAccessInfo( - allowed=allowed, - paid_access=paid_access, - reason=_coerce_str(value.get("reason")), - organisation_id=_coerce_str(value.get("organisation_id")), - effective_at_ms=_coerce_int(value.get("effective_at_ms")), - has_active_subscription=_coerce_bool(value.get("has_active_subscription")), - active_subscription_is_paid=_coerce_bool(value.get("active_subscription_is_paid")), - subscription_tier=_coerce_int(value.get("subscription_tier")), - subscription_monthly_charge=_coerce_float(value.get("subscription_monthly_charge")), - subscription_credits_remaining=_coerce_float(value.get("subscription_credits_remaining")), - purchased_credits_remaining=_coerce_float(value.get("purchased_credits_remaining")), - total_usable_credits=_coerce_float(value.get("total_usable_credits")), - member_spend_cap_exceeded=_coerce_bool(value.get("member_spend_cap_exceeded")), - member_spend_cap_usd=_coerce_float(value.get("member_spend_cap_usd")), - member_spend_usd=_coerce_float(value.get("member_spend_usd")), - member_spend_cap_remaining_usd=_coerce_float(value.get("member_spend_cap_remaining_usd")), - ) + return _coerced_dataclass(NousPaidServiceAccessInfo, value) def _error_info( @@ -836,23 +751,28 @@ def _coerce_bool(value: Any) -> Optional[bool]: return value if isinstance(value, bool) else None -def _coerce_int(value: Any) -> Optional[int]: - if isinstance(value, bool): +def _coerce_num(value: Any, cast): + """``cast(value)`` or None; bools and None are rejected, not coerced.""" + if value is None or isinstance(value, bool): return None try: - if value is None: - return None - return int(value) + return cast(value) except (TypeError, ValueError): return None +def _coerce_int(value: Any) -> Optional[int]: + return _coerce_num(value, int) + + def _coerce_float(value: Any) -> Optional[float]: - if isinstance(value, bool): - return None - try: - if value is None: - return None - return float(value) - except (TypeError, ValueError): - return None + return _coerce_num(value, float) + + +# Annotations are strings (``from __future__ import annotations``). +_COERCERS = { + "Optional[str]": _coerce_str, + "Optional[bool]": _coerce_bool, + "Optional[int]": _coerce_int, + "Optional[float]": _coerce_float, +} diff --git a/hermes_cli/nous_auth_keepalive.py b/hermes_cli/nous_auth_keepalive.py index f86acc705c..c07deb8cf4 100644 --- a/hermes_cli/nous_auth_keepalive.py +++ b/hermes_cli/nous_auth_keepalive.py @@ -160,8 +160,8 @@ def _refresh_selected_pool_entry( ) -> Optional[bool]: """Refresh the current Nous credential pool entry when it is stale. - Returns True when a pool entry exists and is usable/refreshed, False when a - pool exists but no entry can be used, and None when no Nous pool exists. + True = pool entry usable/refreshed; False = pool exists but no usable entry; + None = no Nous pool. """ try: from agent.credential_pool import load_pool @@ -191,12 +191,9 @@ def _refresh_selected_pool_entry( ) key_usable = _agent_key_is_usable(_entry_state(entry), min_key_ttl_seconds) if access_expiring or not key_usable: - refreshed = pool.try_refresh_current() - if refreshed is None: + if pool.try_refresh_current() is None: return False logger.debug("Nous auth keepalive: refreshed credential pool entry") - return True - return True @@ -221,20 +218,15 @@ def refresh_nous_auth_keepalive_once( return False try: - resolve_nous_runtime_credentials( - timeout_seconds=_timeout_seconds(timeout_seconds), - ) + resolve_nous_runtime_credentials(timeout_seconds=_timeout_seconds(timeout_seconds)) logger.debug("Nous auth keepalive: refreshed singleton auth state") return True - except AuthError as exc: - if exc.relogin_required: + except Exception as exc: + if isinstance(exc, AuthError) and exc.relogin_required: logger.info("Nous auth keepalive requires re-login: %s", exc) else: logger.debug("Nous auth keepalive failed: %s", exc) return False - except Exception as exc: - logger.debug("Nous auth keepalive failed: %s", exc) - return False def _keepalive_loop( diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index ba9a7529a1..aa27a9e55a 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -1,32 +1,18 @@ """Nous Portal Remote Spending HTTP client (Phase 2b). -Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal -billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns -read-only entitlement/balance) — this module owns the *write* side: buy credits, -poll a charge, configure auto-reload. +Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal billing screens drive. +Companion to ``hermes_cli/nous_account.py`` (which owns read-only entitlement/balance) — this module +owns the *write* side: buy credits, poll a charge, configure auto-reload. -Design rules: - -- **Money is decimal, never float.** The server emits decimal STRINGS - (``"142.5"`` — not fixed 2dp). We parse with :class:`decimal.Decimal` and never - round-trip through float. -- **This client raises typed exceptions; it does NOT fail open.** Fail-open is the - *caller's* job (the ``agent/billing_view.py`` builders) so each surface can - decide how to degrade. A raw network/HTTP error here surfaces as - :class:`BillingError` (or a subclass) carrying the parsed server ``error`` code, - HTTP status, ``portalUrl`` deep-link, and ``retry_after``. -- **Auth** = the OAuth bearer JWT Hermes already holds for inference - (``get_provider_auth_state("nous")["access_token"]``). No API-key auth on these. -- **Portal base URL** resolves with the same precedence as the device-flow login - (``auth.py``): ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → the - stored auth-state ``portal_base_url`` → the registry default. This is how the - E2E run points the client at a preview deployment with zero code change. +- **Money is decimal, never float.** The server emits decimal STRINGS (``"142.5"`` — not fixed 2dp). +We parse with :class:`decimal.Decimal` and never round-trip through float. """ from __future__ import annotations import json import os +import time import urllib.error import urllib.parse import urllib.request @@ -38,11 +24,6 @@ DEFAULT_PORTAL_BASE_URL = "https://portal.nousresearch.com" # a hung portal doesn't freeze the TUI. DEFAULT_TIMEOUT = 15.0 -# Scope the privileged billing endpoints require. Mirrored from -# hermes_cli.auth.NOUS_BILLING_MANAGE_SCOPE (kept here too so this module has no -# import-time dependency on the much heavier auth module). -BILLING_MANAGE_SCOPE = "billing:manage" - # ============================================================================= # Typed errors @@ -52,10 +33,9 @@ BILLING_MANAGE_SCOPE = "billing:manage" class BillingError(Exception): """A billing HTTP call failed. - Carries everything a surface needs to render the right message + affordance: - the server ``error`` code, HTTP ``status``, an optional human ``message``, the - ``portalUrl`` deep-link (present on every gate denial), and ``retry_after`` - seconds (429/503). ``payload`` is the full parsed JSON body when available. + Carries what a surface needs to render the right message and affordance: server ``error`` + code, HTTP ``status``, optional ``message``, the ``portalUrl`` deep-link (present on every + gate denial), ``retry_after`` seconds (429/503), and the parsed ``payload`` when available. """ def __init__( @@ -89,10 +69,9 @@ class BillingError(Exception): class BillingScopeRequired(BillingError): """``403 insufficient_scope`` — the held token lacks ``billing:manage``. - The lazy step-up trigger: catching this kicks off a fresh device-connect that - requests ``billing:manage`` (and tells the user an ADMIN must select "Allow - Remote Spending"). Also fires mid-session if the scope is stripped on refresh - after the user loses ADMIN. + The lazy step-up trigger: catching this kicks off a fresh device-connect that requests + ``billing:manage`` (and tells the user an ADMIN must select "Allow Remote Spending"). Also fires + mid-session if the scope is stripped on refresh after the user loses ADMIN. """ @@ -103,65 +82,55 @@ class BillingAuthError(BillingError): class BillingRemoteSpendingRevoked(BillingError): """``403 remote_spending_revoked`` — THIS terminal's spending was revoked. - Distinct from ``insufficient_scope`` (never had the grant) and from - ``session_revoked`` (full logout). The terminal stays logged in; only the - money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as - ``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal - MUST disable charge/auto-reload immediately, without waiting for the next - token refresh (the current token still claims the scope for ~15 min). + Distinct from ``insufficient_scope`` (never had the grant) and from ``session_revoked`` (full + logout). The terminal stays logged in; only the money path is cut. ``actor`` is ``"admin"`` or + ``"self"`` (absent → treat as ``"self"``); recovery is **reconnect** (re-consent device-auth). """ class BillingSessionRevoked(BillingAuthError): """``401 session_revoked`` — the whole session was logged out. - Stronger than a spend-revoke: recovery is **re-login** (full device-auth), - not just reconnect. Subclass of :class:`BillingAuthError` so existing 401 - handling still treats it as not-logged-in, but the typed code lets the - surface route to re-login with the right copy. + Stronger than a spend-revoke: recovery is **re-login** (full device-auth), not just reconnect. + Subclass of :class:`BillingAuthError` so existing 401 handling still treats it as not-logged-in, + but the typed code lets the surface route to re-login with the right copy. """ class BillingTransient(BillingError): - """A deterministic non-charge outcome: the request definitely did NOT - reach/complete at Stripe, so it's always safe to retry after backoff — - never the "maybe charged" ambiguity of a real 5xx/timeout. Covers - 429 rate limiting, 503 gate-unavailable, Stripe being down, and the - daily upgrade cap — distinct failure modes that share this one - contract property. Catch this (not the old ad-hoc subclass hierarchy) - wherever the intent is "any transient, definitely-not-charged billing - failure, back off and retry/poll". + """A deterministic non-charge outcome: the request definitely did NOT reach/complete at Stripe, so + it's always safe to retry after backoff — never the "maybe charged" ambiguity of a real + 5xx/timeout. Covers 429 rate limiting, 503 gate-unavailable, Stripe being down, and the daily + upgrade cap — distinct failure modes that share this one contract property. Catch this (not the + old ad-hoc subclass hierarchy) wherever the intent is "any transient, definitely-not-charged + billing failure, back off and retry/poll". """ class BillingRateLimited(BillingTransient): """``429 rate_limited`` or ``503 temporarily_unavailable``. - NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell - the user "try again in N min"; never auto-retry-spam (the limiter is - 5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend - failing closed — back off, do NOT treat as revoked. + NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell the user "try again + in N min"; never auto-retry-spam (the limiter is 5/org/hr + 5/token/hr and easy to dig deeper + into). A 503 is the gate backend failing closed — back off, do NOT treat as revoked. """ class BillingStripeUnavailable(BillingTransient): """``503 stripe_unavailable`` — Stripe itself is down. - TRANSIENT: back off and retry using Retry-After; this is NOT the same as - being throttled by our own rate limiter, so surfaces must not render "rate - limited" copy for it — they should read ``.error`` to tell the two apart. - A BillingTransient sibling of BillingRateLimited (not a subclass) — surfaces - must not render "rate limited" copy for it; read ``.error`` to distinguish it. + TRANSIENT: back off and retry using Retry-After; this is NOT the same as being throttled by our + own rate limiter, so surfaces must not render "rate limited" copy for it — they should read + ``.error`` to tell the two apart. """ class BillingUpgradeCapExceeded(BillingTransient): """``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap. - Distinct from the hourly ``rate_limited`` charge cap (same HTTP status, - different meaning + no useful short-Retry-After backoff). A BillingTransient - sibling of BillingRateLimited (not a subclass) — surfaces must read ``.error`` - to distinguish the failure mode. + Distinct from the hourly ``rate_limited`` charge cap (same HTTP status, different meaning + no + useful short-Retry-After backoff). A BillingTransient sibling of BillingRateLimited (not a + subclass) — surfaces must read ``.error`` to distinguish the failure mode. """ @@ -171,11 +140,7 @@ class BillingUpgradeCapExceeded(BillingTransient): def resolve_portal_base_url(state: Optional[dict[str, Any]] = None) -> str: - """Resolve the portal base URL with login-time precedence. - - ``HERMES_PORTAL_BASE_URL`` → ``NOUS_PORTAL_BASE_URL`` → stored auth-state - ``portal_base_url`` → registry default. Trailing slash stripped. - """ + """Resolve the portal base URL with login-time precedence.""" env = os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") if env and env.strip(): return env.strip().rstrip("/") @@ -189,10 +154,9 @@ def resolve_portal_base_url(state: Optional[dict[str, Any]] = None) -> str: def _absolutize_portal_url(portal_url: Optional[str]) -> Optional[str]: """Resolve a (possibly relative) server portalUrl to an absolute URL. - The server emits ``portalUrl`` relative by design (e.g. ``/billing?topup=open``) - — it doesn't know which deployment the client points at. Resolve it against the - client's portal base (preview / staging / prod) so deep-links are clickable. - Idempotent: an already-absolute URL is returned unchanged (urljoin keeps it). + The server emits ``portalUrl`` relative by design — it doesn't know which deployment the + client points at — so it is resolved against the client's portal base (preview/staging/prod) + to be clickable. Idempotent: absolute URLs pass through unchanged. """ if not (isinstance(portal_url, str) and portal_url.strip()): return portal_url @@ -217,12 +181,9 @@ _token_cache: tuple[float, str, str] | None = None # (cached_at, token, base) def invalidate_cached_token() -> None: """Bust the 30s token cache so post-step-up replays use the freshly-scoped token. - ``_request`` only self-busts the cache on a 401 (an expired/invalid - token), not on a 403 scope denial — so after a step-up grant, the - cache would otherwise still hold the pre-grant unscoped token and - the immediate replay would 403 again. Callers outside this module - (e.g. the CLI's scope step-up flow) call this instead of poking - the private ``_token_cache`` global directly. + ``_request`` only self-busts the cache on a 401 (an expired/invalid token), not on a 403 scope + denial — so after a step-up grant, the cache would otherwise still hold the pre-grant unscoped + token and the immediate replay would 403 again. Callers outside this module (e.g. """ global _token_cache _token_cache = None @@ -243,22 +204,15 @@ def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthE def _resolve_token_and_base(*, use_cache: bool = True) -> tuple[str, str]: """Return ``(access_token, portal_base_url)`` for billing calls. - Uses the same refresh-aware resolver the inference path uses - (``resolve_nous_access_token``), so a short-lived (~15 min) access token that - has expired is transparently refreshed via the stored ``refresh_token`` - instead of failing as "not logged in". Raises :class:`BillingAuthError` only - when there is no usable Nous session at all. - - The result is cached for ``_TOKEN_CACHE_TTL_SECONDS`` to keep the charge poll - loop from re-locking + re-reading the auth store on every 2s tick. Pass - ``use_cache=False`` to force a fresh resolution (e.g. after a 401). + The result is cached for ``_TOKEN_CACHE_TTL_SECONDS`` to keep the charge poll loop from re- + locking + re-reading the auth store on every 2s tick. Pass ``use_cache=False`` to force a fresh + resolution (e.g. after a 401). """ global _token_cache - import time as _time if use_cache and _token_cache is not None: cached_at, token, base = _token_cache - if (_time.time() - cached_at) < _TOKEN_CACHE_TTL_SECONDS: + if (time.time() - cached_at) < _TOKEN_CACHE_TTL_SECONDS: return token, base try: @@ -275,18 +229,15 @@ def _resolve_token_and_base(*, use_cache: bool = True) -> tuple[str, str]: except ImportError: # auth module unavailable — fall back to the raw stored token. token = state.get("access_token") - if isinstance(token, str) and token.strip(): - resolved = (token.strip(), base) - _token_cache = (_time.time(), *resolved) - return resolved - raise _billing_not_logged_in() - - try: - token = resolve_nous_access_token() - except AuthError as exc: - raise _billing_not_logged_in(exc) from exc + if not (isinstance(token, str) and token.strip()): + raise _billing_not_logged_in() + else: + try: + token = resolve_nous_access_token() + except AuthError as exc: + raise _billing_not_logged_in(exc) from exc resolved = (token.strip(), base) - _token_cache = (_time.time(), *resolved) + _token_cache = (time.time(), *resolved) return resolved @@ -296,87 +247,68 @@ def _resolve_token_and_base(*, use_cache: bool = True) -> tuple[str, str]: def _retry_after_seconds(headers: Any) -> Optional[int]: - """Parse a ``Retry-After`` header (integer seconds) — None if absent/bad. - - Thin wrapper around :func:`agent.retry_utils.parse_retry_after_seconds` - (the shared parser also handles HTTP-date forms and clamps negatives). - """ + """Parse a ``Retry-After`` header (integer seconds) — None if absent/bad.""" 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 _raise_for_error( - status: int, payload: dict[str, Any], headers: Any = None -) -> None: +# Error routing for _raise_for_error: server ``error`` code alone, then +# (status, error), then status alone, then the generic fallback. Values are +# (exception class, fallback message when the server sent no ``message``). +# session_revoked is a full logout (→ re-login), stronger than a 401 expired +# token; both stay BillingAuthError-compatible. remote_spending_revoked is NOT +# the same as never having the scope: disable spend UI, recovery is reconnect. +# Business 403s (cli_billing_disabled / role_required / no_payment_method / +# monthly_cap_exceeded / …) fall through to a generic BillingError with +# code/recovery, using the raw error code as the message. +_ERRORS_BY_CODE: dict[str, tuple[type[BillingError], str]] = { + "stripe_unavailable": (BillingStripeUnavailable, "Stripe is temporarily unavailable — try again shortly."), + "upgrade_cap_exceeded": (BillingUpgradeCapExceeded, "Daily plan-change limit reached — try again tomorrow."), +} +_ERRORS_BY_STATUS_CODE: dict[tuple[int, str], tuple[type[BillingError], str]] = { + (401, "session_revoked"): (BillingSessionRevoked, "Your session was logged out — log in again."), + (403, "remote_spending_revoked"): (BillingRemoteSpendingRevoked, "Remote spending was stopped for this terminal."), + (403, "insufficient_scope"): (BillingScopeRequired, "This action needs the billing:manage scope."), +} +_ERRORS_BY_STATUS: dict[int, tuple[type[BillingError], str]] = { + 401: (BillingAuthError, "Authentication required."), + 403: (BillingError, "Billing request denied."), + 429: (BillingRateLimited, "Rate limited — try again shortly."), + 503: (BillingRateLimited, "Rate limited — try again shortly."), +} + + +def _raise_for_error(status: int, payload: dict[str, Any], headers: Any = None) -> None: """Map an HTTP error response to the right typed :class:`BillingError`. - Recognizes the Remote-Spending gate contract (NAS PR #481): - 403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect), - 401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable`` - (gate fail-closed → back off, NOT revoked). The business-denial codes - (``cli_billing_disabled`` + dual ``code:remote_spending_disabled``, - ``role_required``, ``idempotency_conflict``, …) flow through as a generic - BillingError carrying ``error``/``code``/``recovery`` for the surface to map. + Recognizes the Remote-Spending gate contract: 403 ``remote_spending_revoked`` (reconnect), + 401 ``session_revoked`` (re-login), 503 ``temporarily_unavailable`` (fail-closed → back off, + NOT revoked). Business-denial codes flow through as a generic BillingError carrying + ``error``/``code``/``recovery`` for the surface to map. """ - error = payload.get("error") if isinstance(payload, dict) else None - message = payload.get("message") if isinstance(payload, dict) else None - code = payload.get("code") if isinstance(payload, dict) else None - actor = payload.get("actor") if isinstance(payload, dict) else None - recovery = payload.get("recovery") if isinstance(payload, dict) else None - portal_url = _absolutize_portal_url( - payload.get("portalUrl") if isinstance(payload, dict) else None - ) - retry_after = _retry_after_seconds(headers) - + p = payload if isinstance(payload, dict) else {} + error = p.get("error") + message = p.get("message") common = { "status": status, "error": error, - "portal_url": portal_url, - "retry_after": retry_after, - "payload": payload if isinstance(payload, dict) else None, - "actor": actor, - "code": code, - "recovery": recovery, + "portal_url": _absolutize_portal_url(p.get("portalUrl")), + "retry_after": _retry_after_seconds(headers), + "payload": p, + "actor": p.get("actor"), + "code": p.get("code"), + "recovery": p.get("recovery"), } - - if error == "stripe_unavailable": - raise BillingStripeUnavailable( - message or "Stripe is temporarily unavailable — try again shortly.", **common - ) - if error == "upgrade_cap_exceeded": - raise BillingUpgradeCapExceeded( - message or "Daily plan-change limit reached — try again tomorrow.", **common - ) - - if status == 401: - # session_revoked is a full logout (→ re-login), stronger than a 401 - # expired-token. Both stay BillingAuthError-compatible for legacy callers. - if error == "session_revoked": - raise BillingSessionRevoked( - message or "Your session was logged out — log in again.", **common - ) - raise BillingAuthError(message or "Authentication required.", **common) - if status == 403: - # Remote spending was stopped for this terminal (NOT the same as never - # having the scope). Disable spend UI immediately; recovery is reconnect. - if error == "remote_spending_revoked": - raise BillingRemoteSpendingRevoked( - message or "Remote spending was stopped for this terminal.", **common - ) - if error == "insufficient_scope": - raise BillingScopeRequired( - message or "This action needs the billing:manage scope.", **common - ) - # Business 403s (cli_billing_disabled / role_required / no_payment_method / - # monthly_cap_exceeded / …) → generic BillingError with code/recovery. - raise BillingError(message or error or "Billing request denied.", **common) - if status in (429, 503): - raise BillingRateLimited( - message or "Rate limited — try again shortly.", **common - ) - raise BillingError(message or error or f"Billing request failed ({status}).", **common) + key = error if isinstance(error, str) else None + cls, fallback = ( + _ERRORS_BY_CODE.get(key) + or _ERRORS_BY_STATUS_CODE.get((status, key)) + or _ERRORS_BY_STATUS.get(status) + or (BillingError, f"Billing request failed ({status}).") + ) + raise cls(message or (error if cls is BillingError else None) or fallback, **common) def _request( @@ -390,10 +322,10 @@ def _request( ) -> dict[str, Any]: """Make an authenticated billing request; return the parsed JSON dict. - Raises a typed :class:`BillingError` on any non-2xx response (or transport - failure). 2xx with an empty body returns ``{}``. A 401 triggers exactly one - retry with a freshly-resolved token (bypassing the short token cache) so a - cached-but-just-expired token self-heals instead of failing the call. + Raises a typed :class:`BillingError` on any non-2xx response (or transport failure). 2xx with an + empty body returns ``{}``. A 401 triggers exactly one retry with a freshly-resolved token + (bypassing the short token cache) so a cached-but-just-expired token self-heals instead of + failing the call. """ token, base = _resolve_token_and_base(use_cache=not _retried_auth) url = f"{base}{path}" @@ -433,8 +365,7 @@ def _request( # A 401 on a cached token → drop the cache and retry once with a fresh # (refresh-aware) resolve before surfacing the auth error. if exc.code == 401 and not _retried_auth: - global _token_cache - _token_cache = None + invalidate_cached_token() return _request( method, path, @@ -443,7 +374,6 @@ def _request( timeout=timeout, _retried_auth=True, ) - raw = "" try: raw = exc.read().decode("utf-8") except Exception: @@ -472,6 +402,21 @@ def _request( # ============================================================================= +def _require_str(value: Any, message: str, error: str) -> str: + """Return ``value.strip()`` or raise a typed BillingError when it is not a non-blank str.""" + if not (isinstance(value, str) and value.strip()): + raise BillingError(message, error=error) + return value.strip() + + +def _post_idempotent(path: str, body: dict[str, Any], idempotency_key: str, what: str, timeout: float) -> dict[str, Any]: + """POST with a mandatory ``Idempotency-Key`` header (missing header is a server 400).""" + key = _require_str( + idempotency_key, f"Idempotency-Key is required for {what}.", "idempotency_key_required" + ) + return _request("POST", path, body=body, extra_headers={"Idempotency-Key": key}, timeout=timeout) + + def get_billing_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """``GET /api/billing/state`` — role-tiered overview (no scope required).""" return _request("GET", "/api/billing/state", timeout=timeout) @@ -486,19 +431,11 @@ def patch_auto_top_up( ) -> dict[str, Any]: """``PATCH /api/billing/auto-top-up`` — configure auto-reload (scope required). - Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method) - are rejected with 400. Numbers are sent as JSON numbers per the contract. + Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method) are rejected with + 400. Numbers are sent as JSON numbers per the contract. """ - return _request( - "PATCH", - "/api/billing/auto-top-up", - body={ - "enabled": bool(enabled), - "threshold": float(threshold), - "topUpAmount": float(top_up_amount), - }, - timeout=timeout, - ) + body = {"enabled": bool(enabled), "threshold": float(threshold), "topUpAmount": float(top_up_amount)} + return _request("PATCH", "/api/billing/auto-top-up", body=body, timeout=timeout) def post_charge( @@ -509,48 +446,34 @@ def post_charge( ) -> dict[str, Any]: """``POST /api/billing/charge`` — buy credits (scope required). - ``Idempotency-Key`` header is MANDATORY (a missing header is a server 400, not - a default): generate a UUID per user-confirmed purchase and reuse it on retry. - Returns ``202 {chargeId}`` — money is NOT confirmed yet; poll with - :func:`get_charge_status`. + ``Idempotency-Key`` is MANDATORY (missing header is a server 400): generate a UUID per user- + confirmed purchase and reuse it on retry. Returns ``202 {chargeId}`` — money is NOT + confirmed yet; poll with :func:`get_charge_status`. """ - if not (isinstance(idempotency_key, str) and idempotency_key.strip()): - raise BillingError( - "Idempotency-Key is required for a charge.", - error="idempotency_key_required", - ) - return _request( - "POST", - "/api/billing/charge", - body={"amountUsd": float(amount_usd)}, - extra_headers={"Idempotency-Key": idempotency_key.strip()}, - timeout=timeout, + return _post_idempotent( + "/api/billing/charge", {"amountUsd": float(amount_usd)}, idempotency_key, "a charge", timeout ) -def get_charge_status( - charge_id: str, *, timeout: float = DEFAULT_TIMEOUT -) -> dict[str, Any]: +def get_charge_status(charge_id: str, *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """``GET /api/billing/charge/{id}`` — poll a charge (scope required). - Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign - id returns ``{status:"pending"}`` (never 404, never another org's data) — so a - ``pending`` that never resolves past the 5-min cap is a *timeout*, not an error. + Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign id returns + ``{status:"pending"}`` (never 404, never another org's data) — so a ``pending`` that never + resolves past the 5-min cap is a *timeout*, not an error. """ - if not (isinstance(charge_id, str) and charge_id.strip()): - raise BillingError("A charge id is required.", error="invalid_charge_id") + charge_id = _require_str(charge_id, "A charge id is required.", "invalid_charge_id") # urllib does not need manual quoting for the opaque ids the server mints, but # guard against a stray slash that would change the path shape. - safe_id = urllib.parse.quote(charge_id.strip(), safe="") + safe_id = urllib.parse.quote(charge_id, safe="") return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout) def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """``GET /api/billing/subscription`` — current plan, tiers, usage (no scope). - Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no - ``billing:manage`` scope required. Raises :class:`BillingAuthError` - on 401 and :class:`BillingError` on other non-2xx. + Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no ``billing:manage`` scope + required. Raises :class:`BillingAuthError` on 401 and :class:`BillingError` on other non-2xx. """ return _request("GET", "/api/billing/subscription", timeout=timeout) @@ -570,24 +493,17 @@ def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any # which issues live Stripe calls and reveals charge amounts. -def post_subscription_preview( - *, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT -) -> dict[str, Any]: +def post_subscription_preview(*, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """``POST /api/billing/subscription/preview`` — a chargeless effect quote. - Quotes a change to ``subscription_type_id`` without mutating anything: - ``effect`` is ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated - upfront charge), ``scheduled`` (a downgrade → ``effectiveAt`` is period end), - ``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit - would be refused). Also returns the current + target tier and the monthly-credit - delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration - quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts). + Quotes a change to ``subscription_type_id`` without mutating anything: ``effect`` is + ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated upfront charge), + ``scheduled`` (a downgrade → ``effectiveAt`` is period end), ``no_op`` (already on the tier), or + ``blocked`` (``reason`` says why the commit would be refused). """ return _request( - "POST", - "/api/billing/subscription/preview", - body={"subscriptionTypeId": subscription_type_id}, - timeout=timeout, + "POST", "/api/billing/subscription/preview", + body={"subscriptionTypeId": subscription_type_id}, timeout=timeout, ) @@ -599,50 +515,33 @@ def put_subscription_pending_change( ) -> dict[str, Any]: """``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent. - A subscription has at most one pending disposition. Pass ``cancel=True`` to - schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade / - same-price change. UPGRADES are rejected here (they charge immediately — use - :func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``. - Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or - ``{rail, cancelAtPeriodEnd, message}`` for a cancellation. + A subscription has at most one pending disposition: ``cancel=True`` schedules a + cancellation, a ``subscription_type_id`` schedules a downgrade / same-price change. UPGRADES + are rejected here — they charge immediately, use :func:`post_subscription_upgrade`. + Chargeless; needs ``billing:manage``. """ if cancel: body: dict[str, Any] = {"type": "cancellation"} else: - if not ( - isinstance(subscription_type_id, str) and subscription_type_id.strip() - ): - raise BillingError( - "A subscription tier is required to schedule a plan change.", - error="invalid_subscription_type", - ) body = { "type": "tier_change", - "subscriptionTypeId": subscription_type_id.strip(), + "subscriptionTypeId": _require_str( + subscription_type_id, + "A subscription tier is required to schedule a plan change.", + "invalid_subscription_type", + ), } - return _request( - "PUT", - "/api/billing/subscription/pending-change", - body=body, - timeout=timeout, - ) + return _request("PUT", "/api/billing/subscription/pending-change", body=body, timeout=timeout) -def delete_subscription_pending_change( - *, timeout: float = DEFAULT_TIMEOUT -) -> dict[str, Any]: +def delete_subscription_pending_change(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: """``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo). - Removes a scheduled downgrade OR cancellation in one call, restoring the live - active tier and recurring renewal. Chargeless, but it re-enables recurring - spend, so it requires ``billing:manage`` and is honored by the org kill-switch. - Returns ``{rail, cancelAtPeriodEnd: false, message}``. + Removes a scheduled downgrade OR cancellation in one call, restoring the active tier and + renewal. Chargeless, but it re-enables recurring spend, so it requires ``billing:manage`` + and honors the org kill-switch. """ - return _request( - "DELETE", - "/api/billing/subscription/pending-change", - timeout=timeout, - ) + return _request("DELETE", "/api/billing/subscription/pending-change", timeout=timeout) def post_subscription_upgrade( @@ -653,23 +552,14 @@ def post_subscription_upgrade( ) -> dict[str, Any]: """``POST /api/billing/subscription/upgrade`` — immediate paid upgrade. - The SINGLE money route: one Stripe op prorates, charges the card already on the - subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing - header is a server 400, not a default) — reuse the same key on retry so a replay - cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on - success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}`` - when the charge needs 3DS / was declined and must be finished in the portal at - ``recoveryUrl``. Requires ``billing:manage``. + The SINGLE money route: one Stripe op prorates, charges the card already on the subscription, + and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing header is a server 400, not a + default) — reuse the same key on retry so a replay cannot double-charge. """ - if not (isinstance(idempotency_key, str) and idempotency_key.strip()): - raise BillingError( - "Idempotency-Key is required for an upgrade.", - error="idempotency_key_required", - ) - return _request( - "POST", + return _post_idempotent( "/api/billing/subscription/upgrade", - body={"subscriptionTypeId": subscription_type_id}, - extra_headers={"Idempotency-Key": idempotency_key.strip()}, - timeout=timeout, + {"subscriptionTypeId": subscription_type_id}, + idempotency_key, + "an upgrade", + timeout, ) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index a930989e60..ca2f0b659f 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -17,7 +17,7 @@ from utils import is_truthy_value from tools.tool_backend_helpers import ( fal_key_is_configured, has_direct_modal_credentials, - managed_nous_tools_enabled, + managed_nous_tools_enabled, # noqa: F401 (test-patchable re-export) normalize_browser_cloud_provider, normalize_modal_mode, resolve_modal_backend_state, @@ -29,23 +29,70 @@ _DEFAULT_PLATFORM_TOOLSETS = { "cli": "hermes-cli", } -# Maps a tools_config provider's ``managed_nous_feature`` to the tool-pool -# coverage category (hermes_cli.nous_account.TOOL_COVERAGE_CATEGORIES). Lets the -# `hermes tools` picker scope its entitlement gate to the selected backend, so a -# free-tool-pool user is allowed image gen but denied video gen at select time — -# consistent with the per-category feature gates in get_nous_subscription_features. -MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = { - "web": "firecrawl", - "image_gen": "fal", - "video_gen": "fal-video", - "tts": "openai-audio", - # STT shares the TTS coverage category: both ride the managed - # "openai-audio" gateway endpoint (speech + transcriptions). - "stt": "openai-audio", - "browser": "browser-use", - "modal": "modal", + +@dataclass(frozen=True) +class _FeatureSpec: + """Per-feature parameters shared by the status, defaults and Tool Gateway offer surfaces.""" + + label: str + included_by_default: bool + # Tool-pool coverage category (hermes_cli.nous_account.TOOL_COVERAGE_CATEGORIES). Lets the + # `hermes tools` picker scope its entitlement gate to the selected backend, so a free-tool-pool + # user is allowed image gen but denied video gen at select time — consistent with the + # per-category feature gates in get_nous_subscription_features. STT shares the TTS category: + # both ride the managed "openai-audio" gateway endpoint (speech + transcriptions). + coverage: str + # Managed gateway probed for readiness. Video rides image's fal-queue gateway but is gated on + # its own coverage category: the free tool pool funds image and NOT video (paid users get both). + gateway: str + # Config section + selection field written by apply_gateway_defaults and read by + # get_nous_subscription_features (web uses "backend", browser "cloud_provider", else "provider"). + # None = not offered by the Tool Gateway prompt (modal). + section_field: Optional[tuple[str, str]] = None + offer_label: str = "" + direct_label: str = "" + # Direct-credential env vars that keep apply_nous_managed_defaults from switching the + # category to the managed selection (tts/stt also honour resolve_openai_audio_api_key()). + default_direct_env: tuple[str, ...] = () + + +_FEATURES: Dict[str, _FeatureSpec] = { + "web": _FeatureSpec( + "Web tools", True, "firecrawl", "firecrawl", ("web", "backend"), + "Web search & extract (Firecrawl)", "Firecrawl/Exa/Parallel/Keenable key or SearXNG", + ("PARALLEL_API_KEY", "TAVILY_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"), + ), + "image_gen": _FeatureSpec( + "Image generation", True, "fal", "fal-queue", ("image_gen", "provider"), + "Image generation (FAL)", "FAL key", + ), + "video_gen": _FeatureSpec( + "Video generation", False, "fal-video", "fal-queue", ("video_gen", "provider"), + "Video generation (FAL)", "FAL key", + ), + "tts": _FeatureSpec( + "OpenAI TTS", True, "openai-audio", "openai-audio", ("tts", "provider"), + "Text-to-speech (OpenAI TTS)", "OpenAI/ElevenLabs key", ("ELEVENLABS_API_KEY",), + ), + "stt": _FeatureSpec( + "Speech-to-text", True, "openai-audio", "openai-audio", ("stt", "provider"), + "Speech-to-text (OpenAI Whisper)", "OpenAI/Groq/Mistral key", ("GROQ_API_KEY", "MISTRAL_API_KEY"), + ), + "browser": _FeatureSpec( + "Browser automation", True, "browser-use", "browser-use", ("browser", "cloud_provider"), + "Browser automation (Browser Use)", "Browser Use/Browserbase key or Camofox", + ("BROWSER_USE_API_KEY", "BROWSERBASE_API_KEY"), + ), + "modal": _FeatureSpec("Modal execution", False, "modal", "modal"), } +_FEATURE_ORDER = tuple(_FEATURES) +# Public / test-referenced views over the table. +MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {k: s.coverage for k, s in _FEATURES.items()} +_GATEWAY_SECTION_FIELDS = {k: s.section_field for k, s in _FEATURES.items() if s.section_field} +_ALL_GATEWAY_KEYS = tuple(_GATEWAY_SECTION_FIELDS) +_GATEWAY_TOOL_LABELS = {k: _FEATURES[k].offer_label for k in _ALL_GATEWAY_KEYS} + def _uses_gateway(section: object) -> bool: """Return True when a config section explicitly opts into the gateway.""" @@ -57,11 +104,9 @@ def _uses_gateway(section: object) -> bool: def _selected_provider(section: object, name_key: str = "provider") -> Optional[str]: """Return the stored provider string for a config section dict. - Mirrors :func:`tools.tool_backend_helpers.read_selection`'s semantics on - an in-memory section dict: ``"nous"`` for the managed selection (stored - ``nous`` value or legacy ``use_gateway: true``), a vendor name for BYOK - picks, or ``None`` when no selection is stored. Keeping this in lockstep - with the runtime resolver is what stops ``hermes status`` from lying. + Mirrors :func:`tools.tool_backend_helpers.read_selection`'s semantics on an in-memory section + dict: ``"nous"`` for the managed selection (stored ``nous`` value or legacy ``use_gateway: + true``), a vendor name for BYOK picks, or ``None`` when no selection is stored. """ if not isinstance(section, dict): return None @@ -96,40 +141,40 @@ class NousSubscriptionFeatures: features: Dict[str, NousFeatureState] account_info: Optional[NousPortalAccountInfo] = None - @property - def web(self) -> NousFeatureState: - return self.features["web"] - - @property - def image_gen(self) -> NousFeatureState: - return self.features["image_gen"] - - @property - def tts(self) -> NousFeatureState: - return self.features["tts"] - - @property - def stt(self) -> NousFeatureState: - return self.features["stt"] - - @property - def browser(self) -> NousFeatureState: - return self.features["browser"] - - @property - def video_gen(self) -> NousFeatureState: - return self.features["video_gen"] - - @property - def modal(self) -> NousFeatureState: - return self.features["modal"] + def __getattr__(self, name: str) -> NousFeatureState: + # ``features.web`` / ``features.tts`` … resolve to the per-key state. + if name in _FEATURE_ORDER: + return self.features[name] + raise AttributeError(name) def items(self) -> Iterable[NousFeatureState]: - ordered = ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal") - for key in ordered: + for key in _FEATURE_ORDER: yield self.features[key] +def _section(config: Dict[str, object], key: str) -> Dict[str, object]: + """Return ``config[key]`` when it is a dict, else ``{}`` (read-only view).""" + value = config.get(key) + return value if isinstance(value, dict) else {} + + +def _ensure_section(config: Dict[str, object], key: str) -> Dict[str, object]: + """Return ``config[key]`` as a dict, creating/replacing it in ``config`` when missing.""" + value = config.get(key) + if not isinstance(value, dict): + value = {} + config[key] = value + return value + + +def _select_nous(config: Dict[str, object], key: str) -> None: + """Store the managed ``nous`` selection in the ``key`` section (field per _GATEWAY_SECTION_FIELDS).""" + section_key, field = _GATEWAY_SECTION_FIELDS[key] + section = _ensure_section(config, section_key) + section[field] = "nous" + section.pop("use_gateway", None) + + def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]: model_cfg = config.get("model") if isinstance(model_cfg, dict): @@ -151,26 +196,20 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: return False for platform, raw_toolsets in platform_toolsets.items(): - if isinstance(raw_toolsets, list): - toolset_names = list(raw_toolsets) - else: - default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform) - toolset_names = [default_toolset] if default_toolset else [] + toolset_names = list(raw_toolsets) if isinstance(raw_toolsets, list) else [] if not toolset_names: default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform) - if default_toolset: - toolset_names = [default_toolset] + toolset_names = [default_toolset] if default_toolset else [] available_tools: Set[str] = set() for toolset_name in toolset_names: - if not isinstance(toolset_name, str) or not toolset_name: - continue - try: - available_tools.update(resolve_toolset(toolset_name)) - except Exception: - continue + if isinstance(toolset_name, str) and toolset_name: + try: + available_tools.update(resolve_toolset(toolset_name)) + except Exception: + continue - if target_tools and target_tools.issubset(available_tools): + if target_tools.issubset(available_tools): return True return False @@ -198,28 +237,26 @@ def _has_agent_browser() -> bool: # (prior behaviour) rather than crashing the setup/status surface. # Validate the resolved binary actually runs — a dangling global # symlink (issue #48521) is reported by ``which`` but fails at exec. - if agent_browser_runnable(shutil.which("agent-browser")): - return True - - # Hermes-managed Node dirs (Windows installer / POSIX $HERMES_HOME/node) - # are prepended to PATH at runtime but usually absent from the *probe* - # process's PATH. Without this rung a successful install keeps - # reporting "needs setup" on Windows. from hermes_constants import with_hermes_node_path - managed_path = with_hermes_node_path().get("PATH", "") - if managed_path: - managed_hit = shutil.which("agent-browser", path=managed_path) - if managed_hit and agent_browser_runnable(managed_hit): - return True - # Local node_modules/.bin: resolve via PATHEXT-aware ``shutil.which`` so - # Windows picks the executable ``.cmd`` shim — probing the - # extensionless POSIX shim directly fails exec (WinError 193) even - # right after a successful ``npm install``. + # Rungs: PATH; Hermes-managed Node dirs (Windows installer / POSIX + # $HERMES_HOME/node — prepended to PATH at runtime but usually absent + # from the *probe* process's PATH, without which a successful install + # keeps reporting "needs setup" on Windows); local node_modules/.bin + # (PATHEXT-aware ``shutil.which`` so Windows picks the executable + # ``.cmd`` shim — probing the extensionless POSIX shim directly fails + # exec (WinError 193) even right after a successful ``npm install``). local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" - if local_bin_dir.is_dir(): - local_which = shutil.which("agent-browser", path=str(local_bin_dir)) - if local_which and agent_browser_runnable(local_which): + search_paths = [ + None, + with_hermes_node_path().get("PATH", ""), + str(local_bin_dir) if local_bin_dir.is_dir() else "", + ] + for path in search_paths: + if path == "": + continue + hit = shutil.which("agent-browser") if path is None else shutil.which("agent-browser", path=path) + if hit and agent_browser_runnable(hit): return True return False @@ -229,24 +266,20 @@ def _has_agent_browser() -> bool: return False # On Termux, the bare npx fallback is too fragile to advertise as ready — # require a real install, matching check_browser_requirements. - if _requires_real_termux_browser_install(browser_cmd): - return False - return True + return not _requires_real_termux_browser_install(browser_cmd) def _local_browser_runnable() -> bool: """Return True when the *local* browser backend would actually start. - The ``agent-browser`` CLI being present is necessary but not sufficient for - local mode: agent-browser also needs a Chromium build on disk (without one - it hangs on first use until the command timeout fires), unless the - Lightpanda engine is selected — text-only navigation needs no Chromium. + The ``agent-browser`` CLI being present is necessary but not sufficient for local mode: agent- + browser also needs a Chromium build on disk (without one it hangs on first use until the command + timeout fires), unless the Lightpanda engine is selected — text-only navigation needs no + Chromium. - This mirrors the local-mode tail of - :func:`tools.browser_tool.check_browser_requirements`, so the setup/status - surfaces advertise local browser readiness only when the runtime would - actually run it. Cloud providers (Browserbase, Browser Use, Firecrawl) host - their own Chromium and therefore gate on :func:`_has_agent_browser` alone. + This mirrors the local-mode tail of :func:`tools.browser_tool.check_browser_requirements`, so + the setup/status surfaces advertise local browser readiness only when the runtime would actually + run it. """ if not _has_agent_browser(): return False @@ -256,51 +289,44 @@ def _local_browser_runnable() -> bool: # If the runtime probe can't be imported, fall back to binary presence # (prior behaviour) rather than crashing the setup/status surface. return True - if _using_lightpanda_engine(): - return True - return _chromium_installed() + return _using_lightpanda_engine() or _chromium_installed() -def _browser_label(current_provider: str) -> str: - mapping = { +_PROVIDER_LABELS = { + "browser": ("local", { "browserbase": "Browserbase", "browser-use": "Browser Use", "firecrawl": "Firecrawl", "camofox": "Camofox", "local": "Local browser", - } - return mapping.get(current_provider or "local", current_provider or "Local browser") - - -def _tts_label(current_provider: str) -> str: - mapping = { + }), + "tts": ("edge", { "openai": "OpenAI TTS", "elevenlabs": "ElevenLabs", "edge": "Edge TTS", "xai": "xAI TTS", "mistral": "Mistral Voxtral TTS", "neutts": "NeuTTS", - } - return mapping.get(current_provider or "edge", current_provider or "Edge TTS") - - -def _stt_label(current_provider: str) -> str: - mapping = { + }), + "stt": ("local", { "openai": "OpenAI Whisper", "groq": "Groq Whisper", "mistral": "Mistral Voxtral Transcribe", "local": "Local faster-whisper", - } - return mapping.get(current_provider or "local", current_provider or "Local faster-whisper") + }), +} + + +def _provider_label(kind: str, current_provider: str) -> str: + default, mapping = _PROVIDER_LABELS[kind] + return mapping.get(current_provider or default, current_provider or mapping[default]) def _local_stt_backend_available() -> bool: """Whether a local STT backend could serve transcription right now. - True when faster-whisper is importable or a custom local STT command - is configured. Used both for feature detection and to stop - ``apply_nous_managed_defaults`` from flipping a working local setup - to the managed gateway. + True when faster-whisper imports or a custom local STT command is configured. Also stops + ``apply_nous_managed_defaults`` from flipping a working local setup to the managed gateway. """ if get_env_value("HERMES_LOCAL_STT_COMMAND"): return True @@ -327,73 +353,59 @@ def _resolve_browser_feature_state( ) -> tuple[str, bool, bool, bool]: """Resolve browser availability using the same precedence as runtime. - ``browser_local_available`` means "the agent-browser CLI is present" — the - only local requirement for cloud providers, which host their own Chromium. - ``browser_local_runnable`` additionally requires a usable local Chromium - build (or the Lightpanda engine), mirroring the local-mode tail of - :func:`tools.browser_tool.check_browser_requirements`. Local mode must gate - on the latter, or setup/status advertise a browser that fails on first use - when Chromium is missing. + ``browser_local_available`` means "the agent-browser CLI is present" — the only local + requirement for cloud providers, which host their own Chromium. """ + browser_use_managed = bool( + browser_tool_enabled + and browser_local_available + and managed_browser_available + and not direct_browser_use + ) if browser_provider_explicit: + # Camofox is a stored selection (browser.cloud_provider: camofox); + # CAMOFOX_URL is only the server address. + cloud_available = { + "camofox": direct_camofox, + "browserbase": browser_local_available and direct_browserbase, + "browser-use": browser_local_available and (managed_browser_available or direct_browser_use), + "firecrawl": browser_local_available and direct_firecrawl, + } current_provider = browser_provider or "local" - if current_provider == "camofox": - # Camofox is now a stored selection (browser.cloud_provider: - # camofox); CAMOFOX_URL is only the server address. - available = bool(direct_camofox) - active = bool(browser_tool_enabled and available) - return current_provider, available, active, False - if current_provider == "browserbase": - available = bool(browser_local_available and direct_browserbase) - active = bool(browser_tool_enabled and available) - return current_provider, available, active, False - if current_provider == "browser-use": - provider_available = managed_browser_available or direct_browser_use - available = bool(browser_local_available and provider_available) - managed = bool( - browser_tool_enabled - and browser_local_available - and managed_browser_available - and not direct_browser_use - ) - active = bool(browser_tool_enabled and available) - return current_provider, available, active, managed - if current_provider == "firecrawl": - available = bool(browser_local_available and direct_firecrawl) - active = bool(browser_tool_enabled and available) - return current_provider, available, active, False - if current_provider == "camofox": - return current_provider, False, False, False - - current_provider = "local" - available = bool(browser_local_runnable) - active = bool(browser_tool_enabled and available) - return current_provider, available, active, False - + if current_provider not in cloud_available: + current_provider = "local" + available = bool(cloud_available.get(current_provider, browser_local_runnable)) + managed = browser_use_managed if current_provider == "browser-use" else False # Never-configured autodetect: CAMOFOX_URL keeps activating Camofox # exactly as before when no cloud_provider selection was ever stored. - if direct_camofox: + elif direct_camofox: return "camofox", True, bool(browser_tool_enabled), False + elif managed_browser_available or direct_browser_use: + current_provider, available, managed = "browser-use", bool(browser_local_available), browser_use_managed + elif direct_browserbase: + current_provider, available, managed = "browserbase", bool(browser_local_available), False + else: + current_provider, available, managed = "local", bool(browser_local_runnable), False + return current_provider, available, bool(browser_tool_enabled and available), managed - if managed_browser_available or direct_browser_use: - available = bool(browser_local_available) - managed = bool( - browser_tool_enabled - and browser_local_available - and managed_browser_available - and not direct_browser_use - ) - active = bool(browser_tool_enabled and available) - return "browser-use", available, active, managed - if direct_browserbase: - available = bool(browser_local_available) - active = bool(browser_tool_enabled and available) - return "browserbase", available, active, False +def _any_env(*names: str) -> bool: + """True when any of the named env vars (via get_env_value) is set.""" + return any(get_env_value(name) for name in names) - available = bool(browser_local_runnable) - active = bool(browser_tool_enabled and available) - return "local", available, active, False + +def _fal_provider_label(selected: Optional[str], direct_fal: bool, managed: bool) -> str: + if selected not in (None, "nous") or (selected is None and direct_fal): + return "FAL" + return "Nous Subscription" if (managed or selected == "nous") else "" + + +def _account_info_or_none(**kwargs) -> Optional[NousPortalAccountInfo]: + """``get_nous_portal_account_info(**kwargs)``, failing closed to ``None`` on any error.""" + try: + return get_nous_portal_account_info(**kwargs) + except Exception: + return None def get_nous_subscription_features( @@ -407,13 +419,7 @@ def get_nous_subscription_features( model_cfg = _model_config_dict(config) provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous" - try: - if force_fresh: - account_info = get_nous_portal_account_info(force_fresh=True) - else: - account_info = get_nous_portal_account_info() - except Exception: - account_info = None + account_info = _account_info_or_none(**({"force_fresh": True} if force_fresh else {})) # Coarse "entitled to any managed tool" gate: paid access OR a live free # tool pool. Per-backend availability is then narrowed by coverage below @@ -424,23 +430,15 @@ def get_nous_subscription_features( and account_info.tool_gateway_entitled ) nous_auth_present = bool(account_info and account_info.logged_in) - - def _entitled_for(category: str) -> bool: - return bool(account_info and account_info.tool_gateway_entitled_for(category)) subscribed = provider_is_nous or nous_auth_present - web_tool_enabled = _toolset_enabled(config, "web") - image_tool_enabled = _toolset_enabled(config, "image_gen") - video_tool_enabled = _toolset_enabled(config, "video_gen") - tts_tool_enabled = _toolset_enabled(config, "tts") - browser_tool_enabled = _toolset_enabled(config, "browser") - modal_tool_enabled = _toolset_enabled(config, "terminal") + enabled = {key: _toolset_enabled(config, key) for key in ("web", "image_gen", "video_gen", "tts", "browser", "terminal")} + web_tool_enabled, image_tool_enabled, video_tool_enabled = enabled["web"], enabled["image_gen"], enabled["video_gen"] + tts_tool_enabled, browser_tool_enabled, modal_tool_enabled = enabled["tts"], enabled["browser"], enabled["terminal"] - web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {} - tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {} - stt_cfg = config.get("stt") if isinstance(config.get("stt"), dict) else {} - browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {} - terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {} + web_cfg, tts_cfg, stt_cfg, browser_cfg, terminal_cfg = ( + _section(config, key) for key in ("web", "tts", "stt", "browser", "terminal") + ) web_backend = str(web_cfg.get("backend") or "").strip().lower() # Per-capability overrides: if set, they determine which backend is active for @@ -457,230 +455,130 @@ def get_nous_subscription_features( browser_provider = normalize_browser_cloud_provider( browser_cfg.get("cloud_provider") if browser_provider_explicit else None ) - terminal_backend = ( - str(terminal_cfg.get("backend") or "local").strip().lower() - ) - modal_mode = normalize_modal_mode( - terminal_cfg.get("modal_mode") - ) + terminal_backend = str(terminal_cfg.get("backend") or "local").strip().lower() + modal_mode = normalize_modal_mode(terminal_cfg.get("modal_mode")) # Stored selections (strict model): one provider string per category. # "nous" (stored value or legacy use_gateway: true) = managed gateway; # vendor name = that vendor direct; None = never configured (autodetect). - image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {} - video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {} - web_selected = _selected_provider(web_cfg, "backend") - tts_selected = _selected_provider(tts_cfg) - stt_selected = _selected_provider(stt_cfg) - browser_selected = _selected_provider(browser_cfg, "cloud_provider") - image_selected = _selected_provider(image_gen_cfg) - video_selected = _selected_provider(video_gen_cfg) - # Lockstep with tools.tool_backend_helpers.read_selection: these are # merged-config sections, so the legacy DEFAULT_CONFIG-seeded # ``stt.provider: local`` COULD appear here without a user pick on old # versions. Current DEFAULT_CONFIG no longer seeds it, so a merged # ``local`` implies the raw file holds it — a genuine selection. - - # Managed selection flags (replace the legacy use_gateway reads — - # use_gateway is now interpreted only inside _selected_provider). - web_use_gateway = web_selected == "nous" - tts_use_gateway = tts_selected == "nous" - stt_use_gateway = stt_selected == "nous" - browser_use_gateway = browser_selected == "nous" - image_use_gateway = image_selected == "nous" - video_use_gateway = video_selected == "nous" + selected = { + key: _selected_provider(_section(config, section_key), field) + for key, (section_key, field) in _GATEWAY_SECTION_FIELDS.items() + } + # Managed selection flags (use_gateway is interpreted only inside _selected_provider). + use_gateway = {key: value == "nous" for key, value in selected.items()} + web_gw, image_gw, video_gw = use_gateway["web"], use_gateway["image_gen"], use_gateway["video_gen"] + tts_gw, stt_gw, browser_gw = use_gateway["tts"], use_gateway["stt"], use_gateway["browser"] # The "nous" selection is serviced by a concrete vendor implementation — # normalize the current-provider labels so downstream vendor checks hold. - if web_backend == "nous" or web_use_gateway: + if web_backend == "nous" or web_gw: web_backend = "firecrawl" - if tts_provider == "nous" or tts_use_gateway: + if tts_provider == "nous" or tts_gw: tts_provider = "openai" - if stt_provider == "nous" or stt_use_gateway: + if stt_provider == "nous" or stt_gw: stt_provider = "openai" - if browser_provider == "nous" or browser_use_gateway: + if browser_provider == "nous" or browser_gw: browser_provider = "browser-use" - direct_exa = bool(get_env_value("EXA_API_KEY")) - direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) - direct_parallel = bool(get_env_value("PARALLEL_API_KEY")) - direct_tavily = bool(get_env_value("TAVILY_API_KEY")) + # Direct credentials. When the managed selection is stored for a category, + # its direct credentials are suppressed for managed detection. + direct_exa = _any_env("EXA_API_KEY") and not web_gw + direct_firecrawl = _any_env("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL") and not web_gw + direct_parallel = _any_env("PARALLEL_API_KEY") and not web_gw + direct_tavily = _any_env("TAVILY_API_KEY") and not web_gw # Keyless Tavily is opt-in: selecting it in `hermes tools` / setup writes # web.backend (or a per-capability override) without requiring a key. - tavily_selected = "tavily" in {web_backend, web_search_backend, web_extract_backend} - direct_searxng = bool(get_env_value("SEARXNG_URL")) - direct_fal = fal_key_is_configured() - direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent - direct_openai_tts = bool(resolve_openai_audio_api_key()) - direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) - direct_camofox = bool(get_env_value("CAMOFOX_URL")) - direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) - direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) + tavily_selected = "tavily" in {web_backend, web_search_backend, web_extract_backend} and not web_gw + direct_searxng = _any_env("SEARXNG_URL") + fal_configured = fal_key_is_configured() + direct_fal = fal_configured and not image_gw + direct_fal_video = fal_configured and not video_gw # same FAL_KEY; separate var so use_gateway is independent + # OpenAI Whisper reuses the same audio key as OpenAI TTS — + # resolve_openai_audio_api_key() reads VOICE_TOOLS_OPENAI_KEY and falls + # back to OPENAI_API_KEY. + audio_key = bool(resolve_openai_audio_api_key()) + direct_openai_tts = audio_key and not tts_gw + direct_elevenlabs = _any_env("ELEVENLABS_API_KEY") and not tts_gw + direct_camofox = _any_env("CAMOFOX_URL") + direct_browserbase = ( + bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) and not browser_gw + ) + direct_browser_use = _any_env("BROWSER_USE_API_KEY") and not browser_gw direct_modal = has_direct_modal_credentials() - # STT direct providers. OpenAI Whisper reuses the same audio key as - # OpenAI TTS — resolve_openai_audio_api_key() reads VOICE_TOOLS_OPENAI_KEY - # and falls back to OPENAI_API_KEY. The local provider's "direct" - # signal is whether faster-whisper is importable; we lazy-import so - # this module stays cheap on the happy path. - direct_openai_stt = bool(resolve_openai_audio_api_key()) - direct_groq_stt = bool(get_env_value("GROQ_API_KEY")) - direct_mistral_stt = bool(get_env_value("MISTRAL_API_KEY")) - try: - from tools.transcription_tools import _HAS_FASTER_WHISPER - local_stt_available = bool(_HAS_FASTER_WHISPER) or bool( - get_env_value("HERMES_LOCAL_STT_COMMAND") + # STT direct providers. The local provider's "direct" signal is whether + # faster-whisper is importable (lazy-imported so this module stays cheap). + direct_openai_stt = audio_key and not stt_gw + direct_groq_stt = _any_env("GROQ_API_KEY") and not stt_gw + direct_mistral_stt = _any_env("MISTRAL_API_KEY") and not stt_gw + local_stt_available = _local_stt_backend_available() and not stt_gw + + # Managed availability per feature. Strict selection: a stored VENDOR + # selection pins the category to direct credentials — managed availability + # must not light the feature up (the runtime will error, not reroute). + managed = { + key: ( + managed_tools_flag + and nous_auth_present + and is_managed_tool_gateway_ready(_FEATURES[key].gateway) + and bool(account_info and account_info.tool_gateway_entitled_for(_FEATURES[key].coverage)) + and (selected.get(key) is None or use_gateway[key]) ) - except Exception: - local_stt_available = bool(get_env_value("HERMES_LOCAL_STT_COMMAND")) - - # When use_gateway is set, suppress direct credentials for managed detection - if web_use_gateway: - direct_firecrawl = False - direct_exa = False - direct_parallel = False - direct_tavily = False - tavily_selected = False - if image_use_gateway: - direct_fal = False - if video_use_gateway: - direct_fal_video = False - if tts_use_gateway: - direct_openai_tts = False - direct_elevenlabs = False - if stt_use_gateway: - direct_openai_stt = False - direct_groq_stt = False - direct_mistral_stt = False - local_stt_available = False - if browser_use_gateway: - direct_browser_use = False - direct_browserbase = False - - managed_web_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("firecrawl") - and _entitled_for("firecrawl") - ) - managed_image_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("fal-queue") - and _entitled_for("fal") - ) - # Video gen rides the same fal-queue gateway as image gen, but the free tool - # pool funds image and NOT video — so gate it on its own coverage category - # rather than aliasing it to image. (Paid users are entitled to both.) - managed_video_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("fal-queue") - and _entitled_for("fal-video") - ) - managed_tts_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("openai-audio") - and _entitled_for("openai-audio") - ) - # STT and TTS share the same managed gateway endpoint ("openai-audio") - # because the OpenAI audio API covers both /audio/speech (TTS) and - # /audio/transcriptions (STT). One probe (and one entitlement), used by both. - managed_stt_available = managed_tts_available - managed_browser_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("browser-use") - and _entitled_for("browser-use") - ) - managed_modal_available = ( - managed_tools_flag - and nous_auth_present - and is_managed_tool_gateway_ready("modal") - and _entitled_for("modal") - ) + for key in _FEATURE_ORDER + } modal_state = resolve_modal_backend_state( modal_mode, has_direct=direct_modal, - managed_ready=managed_modal_available, + managed_ready=managed["modal"], managed_enabled=managed_tools_flag, ) - - # Strict selection: a stored VENDOR selection pins the category to direct - # credentials — managed availability must not light the feature up (the - # runtime will error, not reroute), and camofox/local selections must not - # be pre-empted by env credentials for other providers. - if web_selected is not None and not web_use_gateway: - managed_web_available = False - if image_selected is not None and not image_use_gateway: - managed_image_available = False - if video_selected is not None and not video_use_gateway: - managed_video_available = False - if tts_selected is not None and not tts_use_gateway: - managed_tts_available = False - if stt_selected is not None and not stt_use_gateway: - managed_stt_available = False - if browser_selected is not None and not browser_use_gateway: - managed_browser_available = False - if browser_selected is not None and browser_selected != "camofox": + if selected["browser"] is not None and selected["browser"] != "camofox": # CAMOFOX_URL is the server address, not a selection: an explicit # different browser choice wins over the env var. direct_camofox = False - - tavily_ready = direct_tavily or tavily_selected - web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl + # Direct web readiness per vendor. web.backend and the per-capability + # overrides (search_backend / extract_backend, split config from #20061) + # may each name a vendor; extract_backend only supports tavily. + web_direct = { + "exa": direct_exa, + "firecrawl": direct_firecrawl, + "parallel": direct_parallel, + "tavily": direct_tavily or tavily_selected, + "searxng": direct_searxng, + } + web_managed = web_backend == "firecrawl" and managed["web"] and not direct_firecrawl web_active = bool( web_tool_enabled and ( web_managed - or (web_backend == "exa" and direct_exa) - or (web_backend == "firecrawl" and direct_firecrawl) - or (web_backend == "parallel" and direct_parallel) - or (web_backend == "tavily" and tavily_ready) - or (web_backend == "searxng" and direct_searxng) - # Per-capability overrides: search_backend or extract_backend may be set - # without web.backend (using the new split config from #20061) - or (web_search_backend == "searxng" and direct_searxng) - or (web_search_backend == "exa" and direct_exa) - or (web_search_backend == "firecrawl" and direct_firecrawl) - or (web_search_backend == "parallel" and direct_parallel) - or (web_search_backend == "tavily" and tavily_ready) - or (web_extract_backend == "tavily" and tavily_ready) + or web_direct.get(web_backend) + or web_direct.get(web_search_backend) + or (web_extract_backend == "tavily" and web_direct["tavily"]) ) ) - web_available = bool( - managed_web_available - or direct_exa - or direct_firecrawl - or direct_parallel - or tavily_ready - or direct_searxng - ) - - image_managed = image_tool_enabled and managed_image_available and not direct_fal - image_active = bool(image_tool_enabled and (image_managed or direct_fal)) - image_available = bool(managed_image_available or direct_fal) - - video_managed = video_tool_enabled and managed_video_available and not direct_fal_video - video_active = bool(video_tool_enabled and (video_managed or direct_fal_video)) - video_available = bool(managed_video_available or direct_fal_video) + web_available = bool(managed["web"] or any(web_direct.values())) tts_current_provider = tts_provider or "edge" tts_managed = ( tts_tool_enabled and tts_current_provider == "openai" - and managed_tts_available + and managed["tts"] and not direct_openai_tts ) - tts_available = bool( - tts_current_provider in {"edge", "neutts"} - or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts)) - or (tts_current_provider == "elevenlabs" and direct_elevenlabs) - or (tts_current_provider == "mistral" and bool(get_env_value("MISTRAL_API_KEY"))) - ) + tts_available = bool({ + "edge": True, + "neutts": True, + "openai": managed["tts"] or direct_openai_tts, + "elevenlabs": direct_elevenlabs, + "mistral": _any_env("MISTRAL_API_KEY"), + }.get(tts_current_provider, False)) tts_active = bool(tts_tool_enabled and tts_available) # STT availability per provider. Unlike TTS, STT isn't a model-callable @@ -690,19 +588,17 @@ def get_nous_subscription_features( stt_current_provider = stt_provider or "local" stt_managed = ( stt_current_provider == "openai" - and managed_stt_available + and managed["stt"] and not direct_openai_stt ) - stt_available = bool( - (stt_current_provider == "local" and local_stt_available) - or (stt_current_provider == "openai" and (managed_stt_available or direct_openai_stt)) - or (stt_current_provider == "groq" and direct_groq_stt) - or (stt_current_provider == "mistral" and direct_mistral_stt) - ) - stt_active = stt_available + stt_available = bool({ + "local": local_stt_available, + "openai": managed["stt"] or direct_openai_stt, + "groq": direct_groq_stt, + "mistral": direct_mistral_stt, + }.get(stt_current_provider, False)) browser_local_available = _has_agent_browser() - browser_local_runnable = _local_browser_runnable() ( browser_current_provider, browser_available, @@ -713,130 +609,93 @@ def get_nous_subscription_features( browser_provider=browser_provider, browser_provider_explicit=browser_provider_explicit, browser_local_available=browser_local_available, - browser_local_runnable=browser_local_runnable, + browser_local_runnable=_local_browser_runnable(), direct_camofox=direct_camofox, direct_browserbase=direct_browserbase, direct_browser_use=direct_browser_use, direct_firecrawl=direct_firecrawl, - managed_browser_available=managed_browser_available, + managed_browser_available=managed["browser"], ) - if terminal_backend != "modal": - modal_managed = False - modal_available = True - modal_active = bool(modal_tool_enabled) - modal_direct_override = False - elif modal_state["selected_backend"] == "managed": - modal_managed = bool(modal_tool_enabled) - modal_available = True - modal_active = bool(modal_tool_enabled) - modal_direct_override = False - elif modal_state["selected_backend"] == "direct": - modal_managed = False - modal_available = True - modal_active = bool(modal_tool_enabled) - modal_direct_override = bool(modal_tool_enabled) - elif modal_mode == "managed": - modal_managed = False - modal_available = bool(managed_modal_available) - modal_active = False - modal_direct_override = False - elif modal_mode == "direct": - modal_managed = False - modal_available = bool(direct_modal) - modal_active = False - modal_direct_override = False + # Modal: a non-modal terminal backend, or a resolved managed/direct + # selection, is always "available"; otherwise report what the mode could use. + modal_selected = modal_state["selected_backend"] if terminal_backend == "modal" else None + if terminal_backend != "modal" or modal_selected in ("managed", "direct"): + modal_available, modal_active = True, bool(modal_tool_enabled) + modal_managed = modal_selected == "managed" and bool(modal_tool_enabled) + modal_direct_override = modal_selected == "direct" and bool(modal_tool_enabled) else: - modal_managed = False - modal_available = bool(managed_modal_available or direct_modal) - modal_active = False - modal_direct_override = False + modal_managed = modal_direct_override = modal_active = False + modal_available = bool( + {"managed": managed["modal"], "direct": direct_modal}.get( + modal_mode, managed["modal"] or direct_modal + ) + ) - # Explicit-configured mirrors the stored selections computed above so - # status/picker markers stay in lockstep with runtime dispatch. - tts_explicit_configured = tts_selected is not None and tts_selected != "edge" - stt_explicit_configured = stt_selected is not None + def _state(key: str, **fields) -> NousFeatureState: + spec = _FEATURES[key] + fields.setdefault("direct_override", fields["active"] and not fields["managed_by_nous"]) + return NousFeatureState(key=key, label=spec.label, included_by_default=spec.included_by_default, **fields) + + def _fal_state(key: str, tool_enabled: bool, direct: bool) -> NousFeatureState: + # image_gen / video_gen: same FAL_KEY, independently gated managed availability. + fal_managed = tool_enabled and managed[key] and not direct + return _state( + key, + available=bool(managed[key] or direct), + active=bool(tool_enabled and (fal_managed or direct)), + managed_by_nous=fal_managed, + toolset_enabled=tool_enabled, + current_provider=_fal_provider_label(selected[key], direct, fal_managed), + explicit_configured=selected[key] is not None or direct, + ) features = { - "web": NousFeatureState( - key="web", - label="Web tools", - included_by_default=True, + "web": _state( + "web", available=web_available, active=web_active, managed_by_nous=web_managed, - direct_override=web_active and not web_managed, toolset_enabled=web_tool_enabled, current_provider=web_backend or web_search_backend or web_extract_backend or "", explicit_configured=bool(web_backend or web_search_backend or web_extract_backend), ), - "image_gen": NousFeatureState( - key="image_gen", - label="Image generation", - included_by_default=True, - available=image_available, - active=image_active, - managed_by_nous=image_managed, - direct_override=image_active and not image_managed, - toolset_enabled=image_tool_enabled, - current_provider="FAL" if (image_selected not in (None, "nous") or (image_selected is None and direct_fal)) else ("Nous Subscription" if (image_managed or image_use_gateway) else ""), - explicit_configured=image_selected is not None or direct_fal, - ), - "video_gen": NousFeatureState( - key="video_gen", - label="Video generation", - included_by_default=False, - available=video_available, - active=video_active, - managed_by_nous=video_managed, - direct_override=video_active and not video_managed, - toolset_enabled=video_tool_enabled, - current_provider="FAL" if (video_selected not in (None, "nous") or (video_selected is None and direct_fal_video)) else ("Nous Subscription" if (video_managed or video_use_gateway) else ""), - explicit_configured=video_selected is not None or direct_fal_video, - ), - "tts": NousFeatureState( - key="tts", - label="OpenAI TTS", - included_by_default=True, + "image_gen": _fal_state("image_gen", image_tool_enabled, direct_fal), + "video_gen": _fal_state("video_gen", video_tool_enabled, direct_fal_video), + "tts": _state( + "tts", available=tts_available, active=tts_active, managed_by_nous=tts_managed, - direct_override=tts_active and not tts_managed, toolset_enabled=tts_tool_enabled, - current_provider=_tts_label(tts_current_provider), - explicit_configured=tts_explicit_configured, + current_provider=_provider_label("tts", tts_current_provider), + # Explicit-configured mirrors the stored selections so status/picker + # markers stay in lockstep with runtime dispatch. + explicit_configured=selected["tts"] is not None and selected["tts"] != "edge", ), - "stt": NousFeatureState( - key="stt", - label="Speech-to-text", - included_by_default=True, + "stt": _state( + "stt", available=stt_available, - active=stt_active, + active=stt_available, managed_by_nous=stt_managed, - direct_override=stt_active and not stt_managed, # STT isn't toolset-gated (gateway middleware calls it # unconditionally on inbound voice), so report True so the # status display doesn't flag it as "tool disabled". toolset_enabled=True, - current_provider=_stt_label(stt_current_provider), - explicit_configured=stt_explicit_configured, + current_provider=_provider_label("stt", stt_current_provider), + explicit_configured=selected["stt"] is not None, ), - "browser": NousFeatureState( - key="browser", - label="Browser automation", - included_by_default=True, + "browser": _state( + "browser", available=browser_available, active=browser_active, managed_by_nous=browser_managed, - direct_override=browser_active and not browser_managed, toolset_enabled=browser_tool_enabled, - current_provider=_browser_label(browser_current_provider), + current_provider=_provider_label("browser", browser_current_provider), explicit_configured=browser_provider_explicit, ), - "modal": NousFeatureState( - key="modal", - label="Modal execution", - included_by_default=False, + "modal": _state( + "modal", available=modal_available, active=modal_active, managed_by_nous=modal_managed, @@ -856,7 +715,10 @@ def get_nous_subscription_features( ) - +def _has_managed_default_direct(key: str) -> bool: + if key in ("tts", "stt") and resolve_openai_audio_api_key(): + return True + return _any_env(*_FEATURES[key].default_direct_env) def apply_nous_managed_defaults( @@ -866,112 +728,53 @@ def apply_nous_managed_defaults( force_fresh: bool = False, ) -> set[str]: features = get_nous_subscription_features(config, force_fresh=force_fresh) + account_info = features.account_info if not ( - features.account_info - and features.account_info.logged_in - and features.account_info.tool_gateway_entitled + account_info + and account_info.logged_in + and account_info.tool_gateway_entitled + and features.provider_is_nous ): return set() - if not features.provider_is_nous: - return set() selected_toolsets = set(enabled_toolsets or ()) changed: set[str] = set() - web_cfg = config.get("web") - if not isinstance(web_cfg, dict): - web_cfg = {} - config["web"] = web_cfg + for key in ("web", "tts", "stt", "browser"): + _ensure_section(config, key) - tts_cfg = config.get("tts") - if not isinstance(tts_cfg, dict): - tts_cfg = {} - config["tts"] = tts_cfg - - stt_cfg = config.get("stt") - if not isinstance(stt_cfg, dict): - stt_cfg = {} - config["stt"] = stt_cfg - - browser_cfg = config.get("browser") - if not isinstance(browser_cfg, dict): - browser_cfg = {} - config["browser"] = browser_cfg - - if "web" in selected_toolsets and not features.web.explicit_configured and not ( - get_env_value("PARALLEL_API_KEY") - or get_env_value("TAVILY_API_KEY") - or get_env_value("FIRECRAWL_API_KEY") - or get_env_value("FIRECRAWL_API_URL") - ): - web_cfg["backend"] = "nous" - web_cfg.pop("use_gateway", None) - changed.add("web") - - if "tts" in selected_toolsets and not features.tts.explicit_configured and not ( - resolve_openai_audio_api_key() - or get_env_value("ELEVENLABS_API_KEY") - ): - tts_cfg["provider"] = "nous" - tts_cfg.pop("use_gateway", None) - changed.add("tts") - - # STT: same pattern as TTS. The DEFAULT_CONFIG seed is "local" - # (requires `pip install faster-whisper`); for Nous subscribers we - # flip it to the managed selection so the managed audio gateway handles - # transcription via the same auth as TTS. Skipped when the user has - # explicitly configured STT, has direct credentials for a non-managed - # provider, has a working local backend (faster-whisper installed or a - # custom local command — strong intent signal that "local" was a choice, - # not just the DEFAULT_CONFIG seed), or isn't entitled to the managed - # "openai-audio" category (flipping would point at a gateway that - # refuses them, silently breaking voice transcription). - if ( - not features.stt.explicit_configured - and not _local_stt_backend_available() - and not ( - resolve_openai_audio_api_key() - or get_env_value("GROQ_API_KEY") - or get_env_value("MISTRAL_API_KEY") - ) - and features.account_info is not None - and features.account_info.tool_gateway_entitled_for("openai-audio") - ): - stt_cfg["provider"] = "nous" - stt_cfg.pop("use_gateway", None) - changed.add("stt") - - if "browser" in selected_toolsets and not features.browser.explicit_configured and not ( - get_env_value("BROWSER_USE_API_KEY") - or get_env_value("BROWSERBASE_API_KEY") - ): - browser_cfg["cloud_provider"] = "nous" - browser_cfg.pop("use_gateway", None) - changed.add("browser") - - if "image_gen" in selected_toolsets and not fal_key_is_configured(): - image_cfg = config.get("image_gen") - if not isinstance(image_cfg, dict): - image_cfg = {} - config["image_gen"] = image_cfg - image_cfg["provider"] = "nous" - image_cfg.pop("use_gateway", None) - changed.add("image_gen") + for key in ("web", "tts", "stt", "browser"): + if features.features[key].explicit_configured or _has_managed_default_direct(key): + continue + if key == "stt": + # STT: same pattern as TTS. The DEFAULT_CONFIG seed is "local" + # (requires `pip install faster-whisper`); for Nous subscribers we + # flip it to the managed selection so the managed audio gateway handles + # transcription via the same auth as TTS. Not toolset-gated. Skipped when + # the user has a working local backend (faster-whisper installed or a + # custom local command — strong intent signal that "local" was a choice, + # not just the DEFAULT_CONFIG seed), or isn't entitled to the managed + # "openai-audio" category (flipping would point at a gateway that + # refuses them, silently breaking voice transcription). + if _local_stt_backend_available() or not ( + account_info is not None and account_info.tool_gateway_entitled_for("openai-audio") + ): + continue + elif key not in selected_toolsets: + continue + _select_nous(config, key) + changed.add(key) # Video gen is not funded by the free tool pool, so only wire managed video # defaults for users entitled to it (paid). Pool-only users keep video off. - if ( - "video_gen" in selected_toolsets - and not fal_key_is_configured() - and features.account_info.tool_gateway_entitled_for("fal-video") - ): - video_cfg = config.get("video_gen") - if not isinstance(video_cfg, dict): - video_cfg = {} - config["video_gen"] = video_cfg - video_cfg["provider"] = "nous" - video_cfg.pop("use_gateway", None) - changed.add("video_gen") + for key, category in (("image_gen", None), ("video_gen", "fal-video")): + if ( + key in selected_toolsets + and not fal_key_is_configured() + and (category is None or account_info.tool_gateway_entitled_for(category)) + ): + _select_nous(config, key) + changed.add(key) return changed @@ -980,110 +783,49 @@ def apply_nous_managed_defaults( # Tool Gateway offer — single Y/n prompt after model selection # --------------------------------------------------------------------------- -_GATEWAY_TOOL_LABELS = { - "web": "Web search & extract (Firecrawl)", - "image_gen": "Image generation (FAL)", - "video_gen": "Video generation (FAL)", - "tts": "Text-to-speech (OpenAI TTS)", - "stt": "Speech-to-text (OpenAI Whisper)", - "browser": "Browser automation (Browser Use)", -} - def _get_gateway_direct_credentials() -> Dict[str, bool]: - """Return a dict of tool_key -> has_direct_credentials.""" + """Return a dict of tool_key -> has_direct_credentials. + + Env-configured keyless local backends count as configured: a reachable self-hosted SearXNG + (autodetected by tools/web_tools.py) or CAMOFOX_URL (never-configured autodetect in + _resolve_browser_feature_state) is a working setup even with no stored selection, so it must + not be classified "unconfigured" and pre-checked (#92647). OpenAI Whisper shares the audio key + with TTS via resolve_openai_audio_api_key(), so it counts for both tts and stt. + """ fal_direct = fal_key_is_configured() + audio_direct = bool(resolve_openai_audio_api_key()) return { - "web": bool( - get_env_value("FIRECRAWL_API_KEY") - or get_env_value("FIRECRAWL_API_URL") - or get_env_value("PARALLEL_API_KEY") - or get_env_value("TAVILY_API_KEY") - or get_env_value("EXA_API_KEY") - # Env-configured keyless local backend: a reachable self-hosted - # SearXNG is a working web setup even with no stored selection - # (the autodetect cascade in tools/web_tools.py picks it up), so - # it must not be classified "unconfigured" and pre-checked (#92647). - or get_env_value("SEARXNG_URL") + "web": _any_env( + "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "PARALLEL_API_KEY", + "TAVILY_API_KEY", "EXA_API_KEY", "SEARXNG_URL", ), "image_gen": fal_direct, "video_gen": fal_direct, - "tts": bool( - resolve_openai_audio_api_key() - or get_env_value("ELEVENLABS_API_KEY") - ), - # STT direct credentials. OpenAI Whisper shares the audio key - # with TTS via resolve_openai_audio_api_key() — counting it here - # too is intentional: if the user has an OpenAI audio key they - # don't need the gateway for either. - "stt": bool( - resolve_openai_audio_api_key() - or get_env_value("GROQ_API_KEY") - or get_env_value("MISTRAL_API_KEY") - ), - "browser": bool( - get_env_value("BROWSER_USE_API_KEY") - or (get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) - # Env-configured keyless local backend: CAMOFOX_URL activates the - # Camofox browser via never-configured autodetect (see - # _resolve_browser_state above), so it counts as configured even - # with no stored cloud_provider selection (#92647). - or get_env_value("CAMOFOX_URL") + "tts": audio_direct or _any_env("ELEVENLABS_API_KEY"), + "stt": audio_direct or _any_env("GROQ_API_KEY", "MISTRAL_API_KEY"), + "browser": ( + _any_env("BROWSER_USE_API_KEY", "CAMOFOX_URL") + or bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) ), } -_GATEWAY_DIRECT_LABELS = { - "web": "Firecrawl/Exa/Parallel/Keenable key or SearXNG", - "image_gen": "FAL key", - "video_gen": "FAL key", - "tts": "OpenAI/ElevenLabs key", - "stt": "OpenAI/Groq/Mistral key", - "browser": "Browser Use/Browserbase key or Camofox", -} - -_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser") - -# Config section + selection field for each gateway key, matching the -# field names ``apply_gateway_defaults`` writes and -# ``get_nous_subscription_features`` reads (web uses "backend", browser -# uses "cloud_provider", everything else uses "provider"). -_GATEWAY_SECTION_FIELDS = { - "web": ("web", "backend"), - "image_gen": ("image_gen", "provider"), - "video_gen": ("video_gen", "provider"), - "tts": ("tts", "provider"), - "stt": ("stt", "provider"), - "browser": ("browser", "cloud_provider"), -} - - def get_gateway_eligible_tools( config: Optional[Dict[str, object]] = None, *, force_fresh: bool = False, ) -> tuple[list[str], list[str], list[str], list[str]]: - """Return (unconfigured, has_direct, explicit_configured, already_managed) - tool key lists. + """Return (unconfigured, has_direct, explicit_configured, already_managed) tool key lists. - - unconfigured: tools with no direct credentials and no explicit - non-nous selection (easy switch, safe to pre-check) - - has_direct: tools where the user has their own API keys - - explicit_configured: tools with an explicit non-nous selection stored - (e.g. ``web.backend: searxng``), including keyless backends that would - otherwise look unconfigured - - already_managed: tools already routed through the gateway - - All lists are empty when the user is not a paid Nous subscriber or - is not using Nous as their provider. + - unconfigured: tools with no direct credentials and no explicit non-nous selection (easy + switch, safe to pre-check) - has_direct: tools where the user has their own API keys - + explicit_configured: tools with an explicit non-nous selection stored (e.g. """ # Fetch entitlement once: it gates the offer (paid access OR a live free tool # pool) AND tells us which categories are covered (the pool funds image but # not video, etc.). Fails closed on any error. - try: - account_info = get_nous_portal_account_info(force_fresh=force_fresh) - except Exception: - return [], [], [], [] + account_info = _account_info_or_none(force_fresh=force_fresh) if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled): return [], [], [], [] @@ -1097,45 +839,35 @@ def get_gateway_eligible_tools( direct = _get_gateway_direct_credentials() - # Check which tools the user has explicitly opted into the gateway for. - # This is distinct from managed_by_nous which fires implicitly when - # no direct keys exist — we only skip the prompt for tools where - # use_gateway was explicitly set. - opted_in = { - "web": _uses_gateway(config.get("web")), - "image_gen": _uses_gateway(config.get("image_gen")), - "video_gen": _uses_gateway(config.get("video_gen")), - "tts": _uses_gateway(config.get("tts")), - "stt": _uses_gateway(config.get("stt")), - "browser": _uses_gateway(config.get("browser")), + # Buckets: already_managed = use_gateway explicitly set (distinct from + # managed_by_nous, which fires implicitly when no direct keys exist); + # explicit_configured = an explicit non-nous selection (e.g. a keyless local + # backend like SearXNG or Camofox) configured on purpose even though it has + # no direct credentials to detect. + buckets: Dict[str, list[str]] = { + "unconfigured": [], "has_direct": [], "explicit_configured": [], "already_managed": [], } - - unconfigured: list[str] = [] - has_direct: list[str] = [] - explicit_configured: list[str] = [] - already_managed: list[str] = [] for key in _ALL_GATEWAY_KEYS: # Only offer tools the user's entitlement actually covers. For a free # tool pool that means image but not video; paid users are covered for # everything. - if not account_info.tool_gateway_entitled_for( - MANAGED_FEATURE_COVERAGE_CATEGORY[key] - ): + if not account_info.tool_gateway_entitled_for(_FEATURES[key].coverage): continue section_key, field = _GATEWAY_SECTION_FIELDS[key] selected = _selected_provider(config.get(section_key), field) - if opted_in.get(key): - already_managed.append(key) + if _uses_gateway(config.get(key)): + bucket = "already_managed" elif selected is not None and selected != "nous": - # An explicit non-nous selection (e.g. a keyless local backend - # like SearXNG or Camofox) is configured on purpose, even - # though it has no direct credentials to detect. - explicit_configured.append(key) + bucket = "explicit_configured" elif direct.get(key): - has_direct.append(key) + bucket = "has_direct" else: - unconfigured.append(key) - return unconfigured, has_direct, explicit_configured, already_managed + bucket = "unconfigured" + buckets[bucket].append(key) + return ( + buckets["unconfigured"], buckets["has_direct"], + buckets["explicit_configured"], buckets["already_managed"], + ) def apply_gateway_defaults( @@ -1144,70 +876,18 @@ def apply_gateway_defaults( ) -> set[str]: """Apply Tool Gateway config for the given tool keys. - Sets ``use_gateway: true`` in each tool's config section so the - runtime prefers the gateway even when direct API keys are present. - - Returns the set of tools that were actually changed. + Sets ``use_gateway: true`` in each tool's section so the runtime prefers the gateway even when + direct API keys are present. Returns the set of tools actually changed. """ changed: set[str] = set() - web_cfg = config.get("web") - if not isinstance(web_cfg, dict): - web_cfg = {} - config["web"] = web_cfg + for key in ("web", "tts", "stt", "browser"): + _ensure_section(config, key) - tts_cfg = config.get("tts") - if not isinstance(tts_cfg, dict): - tts_cfg = {} - config["tts"] = tts_cfg - - stt_cfg = config.get("stt") - if not isinstance(stt_cfg, dict): - stt_cfg = {} - config["stt"] = stt_cfg - - browser_cfg = config.get("browser") - if not isinstance(browser_cfg, dict): - browser_cfg = {} - config["browser"] = browser_cfg - - if "web" in tool_keys: - web_cfg["backend"] = "nous" - web_cfg.pop("use_gateway", None) - changed.add("web") - - if "tts" in tool_keys: - tts_cfg["provider"] = "nous" - tts_cfg.pop("use_gateway", None) - changed.add("tts") - - if "stt" in tool_keys: - stt_cfg["provider"] = "nous" - stt_cfg.pop("use_gateway", None) - changed.add("stt") - - if "browser" in tool_keys: - browser_cfg["cloud_provider"] = "nous" - browser_cfg.pop("use_gateway", None) - changed.add("browser") - - if "image_gen" in tool_keys: - image_cfg = config.get("image_gen") - if not isinstance(image_cfg, dict): - image_cfg = {} - config["image_gen"] = image_cfg - image_cfg["provider"] = "nous" - image_cfg.pop("use_gateway", None) - changed.add("image_gen") - - if "video_gen" in tool_keys: - video_cfg = config.get("video_gen") - if not isinstance(video_cfg, dict): - video_cfg = {} - config["video_gen"] = video_cfg - video_cfg["provider"] = "nous" - video_cfg.pop("use_gateway", None) - changed.add("video_gen") + for key in _ALL_GATEWAY_KEYS: + if key in tool_keys: + _select_nous(config, key) + changed.add(key) return changed @@ -1217,17 +897,10 @@ def prompt_enable_tool_gateway( *, force_fresh: bool = True, ) -> set[str]: - """If eligible tools exist, prompt the user (per tool) to enable the Tool - Gateway. + """If eligible tools exist, prompt the user (per tool) to enable the Tool Gateway. - "Pool enabled" is the trigger: a user with a live free tool pool (or paid - access) is shown a per-tool checklist of the covered managed backends and - picks which to route through the gateway. The free pool funds web/image/ - tts/browser but not video, so the checklist only lists covered tools (the - coverage filter lives in get_gateway_eligible_tools). - - Returns the set of tools that were enabled, or empty set if the user - declined or no tools were eligible. + "Pool enabled" is the trigger: a user with a live free tool pool (or paid access) is shown a + per-tool checklist of the covered managed backends and picks which to route through the gateway. """ # explicit_configured tools (e.g. an explicit `web.backend: searxng`) are # configured on purpose and are never offered here — same treatment as @@ -1246,10 +919,7 @@ def prompt_enable_tool_gateway( # Frame the offer by entitlement: a $0 free-tool-pool user is not on a paid # plan, so don't call it "your subscription". - try: - account_info = get_nous_portal_account_info(force_fresh=False) - except Exception: - account_info = None + account_info = _account_info_or_none(force_fresh=False) pool_only = bool( account_info and account_info.paid_service_access is not True @@ -1275,20 +945,18 @@ def prompt_enable_tool_gateway( offer_keys: list[str] = list(unconfigured) + list(has_direct) labels: list[str] = [_GATEWAY_TOOL_LABELS[k] for k in unconfigured] labels += [ - f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_GATEWAY_DIRECT_LABELS[k]}" + f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_FEATURES[k].direct_label}" for k in has_direct ] pre_selected = [ i for i, k in enumerate(unconfigured) if k not in declined ] - if pool_only: - title = "Your free Nous tool pool — pick the tools to enable:" - else: - title = ( - "Your Nous subscription includes the Tool Gateway — " - "pick the tools to enable:" - ) + title = ( + "Your free Nous tool pool — pick the tools to enable:" + if pool_only + else "Your Nous subscription includes the Tool Gateway — pick the tools to enable:" + ) try: chosen_idx = prompt_checklist(title, labels, pre_selected) @@ -1310,13 +978,9 @@ def prompt_enable_tool_gateway( ) if not chosen_keys: - if newly_declined: - from hermes_cli.config import save_config - - save_config(config) - return set() - - changed = apply_gateway_defaults(config, chosen_keys) + changed: set[str] = set() + else: + changed = apply_gateway_defaults(config, chosen_keys) if changed or newly_declined: from hermes_cli.config import save_config @@ -1337,28 +1001,15 @@ def ensure_nous_portal_access( capability: str = "the Nous Tool Gateway", coverage_category: Optional[str] = None, ) -> bool: - """Make sure the user is entitled to the Nous Tool Gateway, logging in if - needed. + """Make sure the user is entitled to the Nous Tool Gateway, logging in if needed. - Used by ``hermes tools`` when a user selects a Nous-managed Tool Gateway - backend (e.g. "Firecrawl (Nous Portal)"). Unlike ``hermes model``'s Nous - login, this: + It only performs the Nous Portal device-code OAuth (when the user isn't already logged in) and + refreshes entitlement, so the caller can enable the single tool the user picked. - - does NOT change the inference provider (``model.provider`` is untouched), - - does NOT run model selection, and - - does NOT offer the bulk "enable for all tools" Tool Gateway prompt. - - It only performs the Nous Portal device-code OAuth (when the user isn't - already logged in) and refreshes entitlement, so the caller can enable the - single tool the user picked. - - Entitlement is satisfied by paid service access OR a live free tool pool. - When ``coverage_category`` is given (e.g. ``"fal"`` for image gen), the pool - must cover that category specifically — so a pool user selecting video - (``"fal-video"``, not pool-funded) is correctly denied. - - Returns ``True`` when the account is entitled after the flow, ``False`` - otherwise (declined login, login failed, or no entitlement). + Entitlement is satisfied by paid service access OR a live free tool pool. When + ``coverage_category`` is given (e.g. ``"fal"`` for image gen), the pool must cover that category + specifically — so a pool user selecting video (``"fal-video"``, not pool-funded) is correctly + denied. """ def _entitled(account) -> bool: @@ -1369,10 +1020,7 @@ def ensure_nous_portal_access( return account.tool_gateway_entitled # Fast path: already entitled. - try: - info = get_nous_portal_account_info(force_fresh=True) - except Exception: - info = None + info = _account_info_or_none(force_fresh=True) if _entitled(info): return True @@ -1380,10 +1028,7 @@ def ensure_nous_portal_access( if info is None or not info.logged_in: if not _run_nous_portal_login_only(capability=capability): return False - try: - info = get_nous_portal_account_info(force_fresh=True) - except Exception: - info = None + info = _account_info_or_none(force_fresh=True) if _entitled(info): return True @@ -1403,22 +1048,11 @@ def ensure_nous_portal_access( def _run_nous_portal_login_only(*, capability: str) -> bool: """Run the Nous Portal device-code OAuth and persist credentials only. - No model selection, no provider switch, no Tool Gateway bulk prompt. - Returns ``True`` on a successful login, ``False`` if the user declined or - the flow failed. + No model selection, no provider switch, no Tool Gateway bulk prompt. Returns ``True`` on a + successful login, ``False`` if the user declined or the flow failed. """ try: - from hermes_cli.auth import ( - _auth_store_lock, - _load_auth_store, - _nous_device_code_login, - _read_shared_nous_state, - _save_auth_store, - _save_provider_state, - _sync_nous_pool_from_auth_store, - _try_import_shared_nous_state, - _write_shared_nous_state, - ) + import hermes_cli.auth as auth except Exception as exc: # pragma: no cover - defensive print(f" Could not start Nous Portal login: {exc}") return False @@ -1437,12 +1071,11 @@ def _run_nous_portal_login_only(*, capability: str) -> bool: try: # Snapshot the active_provider so a tool-config login never silently # switches the user's inference provider to Nous. - with _auth_store_lock(): - prior_active_provider = _load_auth_store().get("active_provider") + with auth._auth_store_lock(): + prior_active_provider = auth._load_auth_store().get("active_provider") auth_state = None - shared = _read_shared_nous_state() - if shared: + if auth._read_shared_nous_state(): try: do_import = input( " Found existing Nous OAuth credentials. Import them? [Y/n]: " @@ -1450,24 +1083,24 @@ def _run_nous_portal_login_only(*, capability: str) -> bool: except (EOFError, KeyboardInterrupt): do_import = "y" if do_import in {"", "y", "yes"}: - auth_state = _try_import_shared_nous_state(timeout_seconds=15.0) + auth_state = auth._try_import_shared_nous_state(timeout_seconds=15.0) if auth_state is None: - auth_state = _nous_device_code_login() + auth_state = auth._nous_device_code_login() - with _auth_store_lock(): - auth_store = _load_auth_store() - _save_provider_state(auth_store, "nous", auth_state) + with auth._auth_store_lock(): + auth_store = auth._load_auth_store() + auth._save_provider_state(auth_store, "nous", auth_state) # Preserve the user's existing inference provider — this login is # for tool entitlement only, not a provider switch. if prior_active_provider: auth_store["active_provider"] = prior_active_provider else: auth_store.pop("active_provider", None) - _save_auth_store(auth_store) + auth._save_auth_store(auth_store) - _write_shared_nous_state(auth_state) - _sync_nous_pool_from_auth_store() + auth._write_shared_nous_state(auth_state) + auth._sync_nous_pool_from_auth_store() print(" Nous Portal login successful.") return True except KeyboardInterrupt: diff --git a/hermes_cli/onepassword_secrets_cli.py b/hermes_cli/onepassword_secrets_cli.py index 68be4f953a..81e9f36899 100644 --- a/hermes_cli/onepassword_secrets_cli.py +++ b/hermes_cli/onepassword_secrets_cli.py @@ -1,16 +1,8 @@ """CLI handlers for ``hermes secrets onepassword ...``. -Subcommands: - setup — verify the op CLI, set account / token env var, enable - status — show config + op binary + auth + configured references - set — map an env var to an ``op://…`` reference - remove — drop a mapping - sync — resolve references now and show what would be applied (dry-run) - disable — flip ``secrets.onepassword.enabled`` to False - -Unlike Bitwarden, the ``op`` binary is NOT auto-installed: 1Password publishes -the CLI through OS package managers and signed installers, so Hermes expects -an already-installed, already-authenticated ``op`` and never downloads one. +Unlike Bitwarden, the ``op`` binary is NOT auto-installed: 1Password publishes the CLI through OS +package managers and signed installers, so Hermes expects an already-installed, already- +authenticated ``op`` and never downloads one. """ from __future__ import annotations @@ -18,26 +10,51 @@ from __future__ import annotations import argparse import os import subprocess -import sys from pathlib import Path from typing import Optional from rich.console import Console from rich.panel import Panel -from rich.table import Table from agent.secret_sources import onepassword as op_src +from hermes_cli._secrets_common import ( + arg, + cfg_str, + cli_version, + disable_secret_source, + flag, + print_status_panel, + print_table, + register_subcommands, + require_enabled, + rotate_token, + secret_cli_env, + section_cfg, + yn, +) from hermes_cli.config import ( get_env_path, load_config, save_config, save_env_value, ) -from hermes_cli.secret_prompt import masked_secret_prompt _DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN" _DOCS_URL = "https://developer.1password.com/docs/cli/get-started/" +# Old names kept bound: tests call ``onepassword_secrets_cli._op_version`` directly. +_op_version = cli_version +_yn = yn + + +def _op_cfg(cfg: dict) -> dict: + return section_cfg(cfg, "onepassword") + + +def _references(op_cfg: dict) -> dict: + env = op_cfg.get("env") + return env if isinstance(env, dict) else {} + # --------------------------------------------------------------------------- # Argparse wiring — called from hermes_cli.main @@ -46,67 +63,30 @@ _DOCS_URL = "https://developer.1password.com/docs/cli/get-started/" def register_cli(parent_parser: argparse.ArgumentParser) -> None: """Attach the ``onepassword`` subcommand tree to a parent parser.""" - sub = parent_parser.add_subparsers(dest="secrets_op_command") - - setup = sub.add_parser( - "setup", - help="Verify the op CLI, set account / token env var, and enable", - ) - setup.add_argument( - "--account", - help="1Password account shorthand or sign-in address (op --account)", - ) - setup.add_argument( - "--token-env", - help=f"Env var holding a service-account token (default {_DEFAULT_TOKEN_ENV})", - ) - setup.add_argument( - "--token", - help="Service-account token to store in .env non-interactively", - ) - setup.add_argument( - "--binary-path", - help="Absolute path to the op binary (skips PATH lookup)", - ) - setup.set_defaults(func=cmd_setup) - - status = sub.add_parser("status", help="Show config + op binary + references") - status.set_defaults(func=cmd_status) - - token = sub.add_parser( - "token", - help="Rotate the service-account token: validate and store it in .env", - ) - token.add_argument( - "--token", - help="Provide the new token non-interactively (default: masked prompt)", - ) - token.add_argument( - "--no-verify", - action="store_true", - help="Store without probing 1Password first (not recommended)", - ) - token.set_defaults(func=cmd_token) - - set_p = sub.add_parser("set", help="Map an env var to an op:// reference") - set_p.add_argument("env_var", help="Environment variable name, e.g. OPENAI_API_KEY") - set_p.add_argument("reference", help="1Password reference, e.g. op://Private/OpenAI/api key") - set_p.set_defaults(func=cmd_set) - - remove = sub.add_parser("remove", help="Remove an env-var → reference mapping") - remove.add_argument("env_var", help="Environment variable name to unmap") - remove.set_defaults(func=cmd_remove) - - sync = sub.add_parser("sync", help="Resolve references now and report what changed") - sync.add_argument( - "--apply", - action="store_true", - help="Actually export resolved values into the current shell (default: dry-run)", - ) - sync.set_defaults(func=cmd_sync) - - disable = sub.add_parser("disable", help="Turn off the 1Password integration") - disable.set_defaults(func=cmd_disable) + register_subcommands(parent_parser, "secrets_op_command", ( + ("setup", "Verify the op CLI, set account / token env var, and enable", cmd_setup, ( + arg("--account", "1Password account shorthand or sign-in address (op --account)"), + arg("--token-env", f"Env var holding a service-account token (default {_DEFAULT_TOKEN_ENV})"), + arg("--token", "Service-account token to store in .env non-interactively"), + arg("--binary-path", "Absolute path to the op binary (skips PATH lookup)"), + )), + ("status", "Show config + op binary + references", cmd_status, ()), + ("token", "Rotate the service-account token: validate and store it in .env", cmd_token, ( + arg("--token", "Provide the new token non-interactively (default: masked prompt)"), + flag("--no-verify", "Store without probing 1Password first (not recommended)"), + )), + ("set", "Map an env var to an op:// reference", cmd_set, ( + arg("env_var", "Environment variable name, e.g. OPENAI_API_KEY"), + arg("reference", "1Password reference, e.g. op://Private/OpenAI/api key"), + )), + ("remove", "Remove an env-var → reference mapping", cmd_remove, ( + arg("env_var", "Environment variable name to unmap"), + )), + ("sync", "Resolve references now and report what changed", cmd_sync, ( + flag("--apply", "Actually export resolved values into the current shell (default: dry-run)"), + )), + ("disable", "Turn off the 1Password integration", cmd_disable, ()), + )) # --------------------------------------------------------------------------- @@ -135,10 +115,11 @@ def cmd_setup(args: argparse.Namespace) -> int: binary_path = (args.binary_path or op_cfg.get("binary_path", "") or "").strip() binary = op_src.find_op(binary_path) if binary is None: - if binary_path: - console.print(f" [red]✗ {binary_path} is not an executable op binary.[/red]") - else: - console.print(" [red]✗ op not found on PATH.[/red]") + console.print( + f" [red]✗ {binary_path} is not an executable op binary.[/red]" + if binary_path + else " [red]✗ op not found on PATH.[/red]" + ) console.print(f" Install the 1Password CLI: {_DOCS_URL}") return 1 console.print(f" [green]✓[/green] {binary} ({_op_version(binary)})") @@ -197,42 +178,31 @@ def cmd_setup(args: argparse.Namespace) -> int: def cmd_status(args: argparse.Namespace) -> int: console = Console() - cfg = load_config() - op_cfg = (cfg.get("secrets") or {}).get("onepassword") or {} + op_cfg = _op_cfg(load_config()) enabled = bool(op_cfg.get("enabled")) - account = str(op_cfg.get("account", "") or "").strip() + account = cfg_str(op_cfg, "account") token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) - binary_path = str(op_cfg.get("binary_path", "") or "").strip() - references = op_cfg.get("env") if isinstance(op_cfg.get("env"), dict) else {} + binary_path = cfg_str(op_cfg, "binary_path") + references = _references(op_cfg) token_set = bool(os.environ.get(token_env)) binary = op_src.find_op(binary_path) - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("", style="bold") - table.add_column("") - table.add_row("Enabled", _yn(enabled)) - table.add_row("Account", account or "[dim]default[/dim]") - table.add_row("Token env var", token_env) - table.add_row("Token in env", _yn(token_set)) - table.add_row("Override existing", _yn(bool(op_cfg.get("override_existing", True)))) - table.add_row("Cache TTL (s)", str(op_cfg.get("cache_ttl_seconds", 300))) - if binary: - table.add_row("op binary", f"{binary} ({_op_version(binary)})") - else: - table.add_row("op binary", "[yellow]not found[/yellow]") - table.add_row("References", str(len(references))) - - console.print(Panel(table, title="1Password secret source", border_style="cyan")) + print_status_panel(console, "1Password secret source", ( + ("Enabled", _yn(enabled)), + ("Account", account or "[dim]default[/dim]"), + ("Token env var", token_env), + ("Token in env", _yn(token_set)), + ("Override existing", _yn(bool(op_cfg.get("override_existing", True)))), + ("Cache TTL (s)", str(op_cfg.get("cache_ttl_seconds", 300))), + ("op binary", f"{binary} ({_op_version(binary)})" if binary else "[yellow]not found[/yellow]"), + ("References", str(len(references))), + )) if references: - ref_table = Table(show_header=True, header_style="bold") - ref_table.add_column("Env var", style="cyan") - ref_table.add_column("Reference") - for name in sorted(references): - ref_table.add_row(name, str(references[name])) - console.print(ref_table) + print_table(console, (("Env var", {"style": "cyan"}), "Reference"), + ((name, str(references[name])) for name in sorted(references))) if not enabled: console.print("\n Run [cyan]hermes secrets onepassword setup[/cyan] to enable.") @@ -267,11 +237,9 @@ def cmd_set(args: argparse.Namespace) -> int: cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) - env_map = op_cfg.get("env") - if not isinstance(env_map, dict): - env_map = {} - op_cfg["env"] = env_map - env_map[args.env_var] = valid[args.env_var] + if not isinstance(op_cfg.get("env"), dict): + op_cfg["env"] = {} + op_cfg["env"][args.env_var] = valid[args.env_var] save_config(cfg) console.print( f"[green]✓[/green] mapped [cyan]{args.env_var}[/cyan] → " @@ -302,77 +270,58 @@ def cmd_remove(args: argparse.Namespace) -> int: def cmd_token(args: argparse.Namespace) -> int: """Rotate the 1Password service-account token without the full setup flow. - Prompts for (or accepts via ``--token``) a new service-account token, - verifies it with ``op whoami`` (unless ``--no-verify``), and only then - persists it to .env — so a bad paste never bricks the working token. + Prompts for (or accepts via ``--token``) a new service-account token, verifies it with ``op + whoami`` (unless ``--no-verify``), and only then persists it to .env — so a bad paste never + bricks the working token. """ console = Console() - cfg = load_config() - op_cfg = (cfg.get("secrets") or {}).get("onepassword") or {} + op_cfg = _op_cfg(load_config()) token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) - account = str(op_cfg.get("account", "") or "").strip() - binary_path = str(op_cfg.get("binary_path", "") or "").strip() + account = cfg_str(op_cfg, "account") + binary_path = cfg_str(op_cfg, "binary_path") - token = (args.token or "").strip() - if not token: - if not sys.stdin.isatty(): - console.print("[red]No TTY — pass the token with --token.[/red]") - return 1 - console.print( - "Create a new service-account token at " - "https://my.1password.com → Developer → Service Accounts.\n" - ) - token = masked_secret_prompt(f"Paste new token ({token_env}): ").strip() - if not token: - console.print("[red]Empty token, aborting.[/red]") - return 1 - - if not args.no_verify: + def verify(token: str) -> bool: binary = op_src.find_op(binary_path) if binary is None: console.print( f"[red]op CLI not found — install it ({_DOCS_URL}) or " "re-run with --no-verify to store anyway.[/red]" ) - return 1 + return False console.print("Verifying with `op whoami`…") who = _op_whoami(binary, account, token_value=token) if who is None: console.print( "[red]✗ New token was rejected by op — nothing was changed.[/red]" ) - return 1 + return False console.print(f"[green]✓ Token accepted[/green] ({who}).") + return True - save_env_value(token_env, token) - os.environ[token_env] = token - # Cached resolutions are keyed on the previous token's fingerprint; - # drop them so the next startup resolves fresh with the new credential. - op_src.clear_caches() - console.print( - f"[green]✓[/green] stored in {get_env_path()} as {token_env}. " - "Takes effect on the next Hermes invocation." - ) - if not op_cfg.get("enabled"): - console.print( + return rotate_token( + console, args.token, token_env, + flag="--token", + intro=( + "Create a new service-account token at " + "https://my.1password.com → Developer → Service Accounts.\n" + ), + prompt=f"Paste new token ({token_env}): ", + verify=None if args.no_verify else verify, + save=save_env_value, env_path=get_env_path, clear_caches=op_src.clear_caches, + disabled_note=None if op_cfg.get("enabled") else ( "[yellow]Note: the 1Password integration is currently disabled — " "run `hermes secrets onepassword setup` to turn it on.[/yellow]" - ) - return 0 + ), + ) def cmd_sync(args: argparse.Namespace) -> int: console = Console() - cfg = load_config() - op_cfg = (cfg.get("secrets") or {}).get("onepassword") or {} - if not op_cfg.get("enabled"): - console.print( - "[yellow]1Password integration is disabled. Run " - "`hermes secrets onepassword setup` first.[/yellow]" - ) + op_cfg = _op_cfg(load_config()) + if not require_enabled(console, op_cfg, "1Password", "onepassword"): return 1 - references = op_cfg.get("env") if isinstance(op_cfg.get("env"), dict) else {} + references = _references(op_cfg) if not references: console.print( "[yellow]No op:// references configured. Add one with " @@ -380,9 +329,9 @@ def cmd_sync(args: argparse.Namespace) -> int: ) return 0 - account = str(op_cfg.get("account", "") or "").strip() + account = cfg_str(op_cfg, "account") token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) - binary_path = str(op_cfg.get("binary_path", "") or "").strip() + binary_path = cfg_str(op_cfg, "binary_path") # --apply delegates to the same code path startup uses, so the skip / # override / token-guard policy lives in exactly one place. @@ -399,16 +348,12 @@ def cmd_sync(args: argparse.Namespace) -> int: if result.error: console.print(f"[red]{result.error}[/red]") return 1 - table = Table(show_header=True, header_style="bold") - table.add_column("Env var", style="cyan") - table.add_column("Action") - for name in sorted(result.applied): - table.add_row(name, "[green]exported[/green]") - for name in sorted(result.skipped): - table.add_row(name, "[dim]skipped (already set / token var)[/dim]") - console.print(table) - for w in result.warnings: - console.print(f"[yellow]warning:[/yellow] {w}") + print_table( + console, (("Env var", {"style": "cyan"}), "Action"), + [(name, "[green]exported[/green]") for name in sorted(result.applied)] + + [(name, "[dim]skipped (already set / token var)[/dim]") for name in sorted(result.skipped)], + result.warnings, + ) console.print( f"\n [green]Exported {len(result.applied)} secret(s) into current " "process.[/green]" @@ -429,25 +374,19 @@ def cmd_sync(args: argparse.Namespace) -> int: return 1 override = bool(op_cfg.get("override_existing", True)) - table = Table(show_header=True, header_style="bold") - table.add_column("Env var", style="cyan") - table.add_column("Action") - for name in sorted(references): + + def action(name: str) -> str: if name == token_env: - table.add_row(name, "[dim]skip (token var)[/dim]") - elif name not in secrets: - table.add_row(name, "[red]unresolved (see warnings)[/red]") - elif os.environ.get(name) and not override: - table.add_row(name, "[dim]skip (already set)[/dim]") - else: - already = bool(os.environ.get(name)) - table.add_row( - name, - "[green]would export[/green]" + (" (overrides)" if already else ""), - ) - console.print(table) - for w in warnings: - console.print(f"[yellow]warning:[/yellow] {w}") + return "[dim]skip (token var)[/dim]" + if name not in secrets: + return "[red]unresolved (see warnings)[/red]" + already = bool(os.environ.get(name)) + if already and not override: + return "[dim]skip (already set)[/dim]" + return "[green]would export[/green]" + (" (overrides)" if already else "") + + print_table(console, (("Env var", {"style": "cyan"}), "Action"), + ((name, action(name)) for name in sorted(references)), warnings) console.print( "\n This was a dry-run — references resolve automatically on the next " "[cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] to export " @@ -457,19 +396,14 @@ def cmd_sync(args: argparse.Namespace) -> int: def cmd_disable(args: argparse.Namespace) -> int: - console = Console() - cfg = load_config() - op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) - op_cfg["enabled"] = False - save_config(cfg) - console.print( + return disable_secret_source( + "onepassword", "[green]Disabled.[/green] 1Password references will NOT be resolved on the " "next Hermes invocation.\n" " Your reference mappings are left in config.yaml — remove them with " "[cyan]hermes secrets onepassword remove ENV_VAR[/cyan] if you no longer " - "need them." + "need them.", ) - return 0 # --------------------------------------------------------------------------- @@ -477,44 +411,18 @@ def cmd_disable(args: argparse.Namespace) -> int: # --------------------------------------------------------------------------- -def _yn(b: bool) -> str: - return "[green]yes[/green]" if b else "[dim]no[/dim]" - - -def _op_version(binary: Path) -> str: - try: - res = subprocess.run( - [str(binary), "--version"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - ) - if res.returncode == 0: - return (res.stdout or res.stderr).strip().splitlines()[0] - except (OSError, subprocess.TimeoutExpired): - pass - return "version unknown" - - def _op_whoami( binary: Path, account: str, *, token_value: str = "" ) -> Optional[str]: """Return a short identity string if op is authenticated, else None. - ``token_value``, when given, is passed to the child as - ``OP_SERVICE_ACCOUNT_TOKEN`` so a candidate token can be probed - without touching the caller's environment. + ``token_value`` is passed to the child as ``OP_SERVICE_ACCOUNT_TOKEN`` so a candidate token + can be probed without touching the caller's environment. """ cmd = [str(binary), "whoami"] if account: cmd += ["--account", account] - # 1Password CLI child: intentionally receives the service-account token — - # no scrub, no HOME rewrite (op stores auth state under the real home). - from tools.environments.local import build_subprocess_env - env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) - env.setdefault("NO_COLOR", "1") + env = secret_cli_env() if token_value: env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value try: diff --git a/hermes_cli/pairing.py b/hermes_cli/pairing.py index 17b2173d8b..56c4c91ce1 100644 --- a/hermes_cli/pairing.py +++ b/hermes_cli/pairing.py @@ -1,12 +1,4 @@ -""" -CLI commands for the DM pairing system. - -Usage: - hermes pairing list # Show all pending + approved users - hermes pairing approve # Approve a pairing request - hermes pairing revoke # Revoke user access - hermes pairing clear-pending # Clear all expired/pending codes -""" +"""CLI commands for the DM pairing system.""" def pairing_command(args): """Handle hermes pairing subcommands.""" diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 76e5553f39..cfbb7013db 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -1,23 +1,4 @@ -"""``hermes portal`` — the human-readable entry point for Nous Portal. - -Running ``hermes portal`` with no subcommand performs the one-shot Portal -onboarding: OAuth login, pick a Nous model, switch the inference provider to -Nous, and offer to enable the Tool Gateway. It is the friendly alias for -``hermes auth add nous --type oauth`` (which still works), is identical to -``hermes setup --portal``, and runs the same Nous flow as the first-time quick -setup. - -Subcommands: - (none) Log in to Nous Portal + set it up (one-shot onboarding). - login Explicit alias for the default one-shot onboarding. - info Show Portal auth state + which Tool Gateway tools are routed. - open Open the Portal subscription page in the user's default browser. - tools List Tool Gateway tools and which are active in the current config. - -This command is intentionally minimal — it does not duplicate functionality -already in ``hermes auth`` or ``hermes tools``. It's the onboarding + discovery -surface for the Portal subscription itself. -""" +"""``hermes portal`` — the human-readable entry point for Nous Portal.""" from __future__ import annotations import sys @@ -31,6 +12,17 @@ SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription" DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway" +def _feature_state(feat, *, via_nous: str) -> str: + """Routing column shared by `portal info` and `portal tools`.""" + if feat.managed_by_nous: + return color(via_nous, Colors.GREEN) + if feat.active and feat.current_provider: + return feat.current_provider + if feat.active: + return "active" + return color("not configured", Colors.DIM) + + def _cmd_status(args) -> int: """Show Portal auth + Tool Gateway routing summary.""" from hermes_cli.auth import get_nous_auth_status_local @@ -82,17 +74,8 @@ def _cmd_status(args) -> int: print(" (could not resolve subscription state)") return 0 - rows = [] - for feat in features.items(): - if feat.managed_by_nous: - state = color("via Nous Portal", Colors.GREEN) - elif feat.active and feat.current_provider: - state = feat.current_provider - elif feat.active: - state = "active" - else: - state = color("not configured", Colors.DIM) - rows.append((feat.label, state)) + rows = [(feat.label, _feature_state(feat, via_nous="via Nous Portal")) + for feat in features.items()] width = max((len(r[0]) for r in rows), default=0) for label, state in rows: @@ -152,14 +135,8 @@ def _cmd_tools(args) -> int: feat = features.features.get(key) if feat is None: state = color("unknown", Colors.DIM) - elif feat.managed_by_nous: - state = color("✓ via Nous Portal", Colors.GREEN) - elif feat.active and feat.current_provider: - state = feat.current_provider - elif feat.active: - state = "active" else: - state = color("not configured", Colors.DIM) + state = _feature_state(feat, via_nous="✓ via Nous Portal") print(f" {label:<{label_width}} partner: {partner:<14} {state}") print() @@ -171,11 +148,9 @@ def _cmd_tools(args) -> int: def _cmd_login(args) -> int: """Run the one-shot Nous Portal onboarding (login + model + provider + tools). - This is the human-readable front door for `hermes auth add nous --type - oauth`. It reuses the exact wiring behind `hermes setup --portal` (which in - turn runs the same Nous flow as the first-time quick setup), so the - commands stay in lockstep: device-code login, pick a Nous model, switch the - inference provider to Nous, then offer the Tool Gateway opt-in. + Front door for ``hermes auth add nous --type oauth``. Reuses the exact wiring behind ``hermes + setup --portal`` so the commands stay in lockstep: device-code login, pick a Nous model, switch + the inference provider to Nous, then offer the Tool Gateway opt-in. """ from hermes_cli.setup import _run_portal_one_shot @@ -189,21 +164,26 @@ def _cmd_login(args) -> int: return 0 +# Default (None/"") is the one-shot onboarding — `hermes portal` is the +# human-readable alias for `hermes auth add nous --type oauth` / +# `hermes setup --portal`. `status` kept as a back-compat alias for `info`. +_SUBCOMMANDS = { + None: _cmd_login, + "": _cmd_login, + "login": _cmd_login, + "info": _cmd_status, + "status": _cmd_status, + "open": _cmd_open, + "tools": _cmd_tools, +} + + def portal_command(args) -> int: """Top-level dispatch for `hermes portal `.""" sub = getattr(args, "portal_command", None) - if sub in {None, "", "login"}: - # Default to the one-shot onboarding — `hermes portal` is the - # human-readable alias for `hermes auth add nous --type oauth` / - # `hermes setup --portal`. - return _cmd_login(args) - if sub in {"info", "status"}: - # `status` kept as a back-compat alias for the prior default. - return _cmd_status(args) - if sub == "open": - return _cmd_open(args) - if sub == "tools": - return _cmd_tools(args) + handler = _SUBCOMMANDS.get(sub) + if handler is not None: + return handler(args) print(f"Unknown portal subcommand: {sub}", file=sys.stderr) print("Run `hermes portal -h` for usage.", file=sys.stderr) return 1 @@ -224,23 +204,15 @@ def add_parser(subparsers) -> None: ) portal_sub = portal_parser.add_subparsers(dest="portal_command") - portal_sub.add_parser( - "login", - help="Log in to Nous Portal + set it up (default; one-shot onboarding)", - ) - portal_sub.add_parser( - "info", - help="Show Portal auth + Tool Gateway routing summary", - ) - # `status` retained as a hidden back-compat alias for `info`. - portal_sub.add_parser("status") - portal_sub.add_parser( - "open", - help="Open the Portal subscription page in your default browser", - ) - portal_sub.add_parser( - "tools", - help="List Tool Gateway tools and which are routed via Nous", - ) + # `status` retained as a hidden (no help) back-compat alias for `info`; + # registration order is the order shown in `hermes portal -h`. + for name, help_text in ( + ("login", "Log in to Nous Portal + set it up (default; one-shot onboarding)"), + ("info", "Show Portal auth + Tool Gateway routing summary"), + ("status", None), + ("open", "Open the Portal subscription page in your default browser"), + ("tools", "List Tool Gateway tools and which are routed via Nous"), + ): + portal_sub.add_parser(name, **({} if help_text is None else {"help": help_text})) portal_parser.set_defaults(func=portal_command) diff --git a/hermes_cli/secret_prompt.py b/hermes_cli/secret_prompt.py index 1f8a4df485..5f501d8b96 100644 --- a/hermes_cli/secret_prompt.py +++ b/hermes_cli/secret_prompt.py @@ -56,8 +56,8 @@ def _collect_masked_input( def masked_secret_prompt(prompt: str, *, mask: str = "*") -> str: """Prompt for a secret while showing masked typing feedback. - Falls back to ``getpass.getpass`` when stdin/stdout are not interactive or - when raw terminal handling is unavailable. + Falls back to ``getpass.getpass`` when stdin/stdout are not interactive or when raw terminal + handling is unavailable. """ stdin = sys.stdin stdout = sys.stdout diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index 60128da00e..9b5e7a0bf9 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -1,12 +1,4 @@ -"""CLI handlers for ``hermes secrets bitwarden ...``. - -Subcommands: - setup — interactive wizard: install bws, prompt for token + project, test fetch - status — show current config + binary version + token validation status - sync — run a fetch right now and show what would be applied (dry-run friendly) - disable — flip ``secrets.bitwarden.enabled`` to False - install — just download the bws binary (no token / project required) -""" +"""CLI handlers for ``hermes secrets bitwarden ...``.""" from __future__ import annotations @@ -37,6 +29,22 @@ from rich.table import Table # bump both together when pinning a new bws release. _BWS_VERSION = "2.0.0" +from hermes_cli._secrets_common import ( + arg, + cfg_str, + cli_version, + disable_secret_source, + flag, + print_status_panel, + print_table, + prompt_index, + register_subcommands, + require_enabled, + rotate_token, + secret_cli_env, + section_cfg, + yn, +) from hermes_cli.config import ( get_env_path, load_config, @@ -45,6 +53,20 @@ from hermes_cli.config import ( ) from hermes_cli.secret_prompt import masked_secret_prompt +# Old names kept bound: tests monkeypatch ``secrets_cli._bws_version``. +_bws_version = cli_version +_yn = yn + + +_NOT_BSM_TOKEN_WARNING = ( + "[yellow]Warning: token doesn't start with '0.' — usually that means " + "you pasted something other than a BSM access token.[/yellow]" +) +_NOT_BSM_TOKEN_WARNING_CONTINUING = ( + " [yellow]Warning: token doesn't start with '0.' — usually that means " + "you pasted something other than a BSM access token. Continuing anyway.[/yellow]" +) + def _load_bw(): """Import ``agent.secret_sources.bitwarden`` on first use (crypto payload).""" @@ -56,12 +78,9 @@ def _load_bw(): def __getattr__(name: str): """PEP 562 module-level lazy resolver. - Existing callers (and upstream tests) monkeypatch attributes on - ``hermes_cli.secrets_cli.bw`` directly. Resolving that attribute at - module-import time would re-import ``cryptography`` eagerly — the very - self-lock we are preventing (#86781). Defer the backend import until - the first actual attribute access, so ``import hermes_cli.secrets_cli`` - stays crypto-free while ``secrets_cli.bw.find_bws`` still resolves. + Existing callers (and upstream tests) monkeypatch attributes on ``hermes_cli.secrets_cli.bw`` + directly. Resolving that attribute at module-import time would re-import ``cryptography`` + eagerly — the very self-lock we are preventing (#86781). """ if name == "bw": return _load_bw() @@ -74,78 +93,31 @@ def __getattr__(name: str): def register_cli(parent_parser: argparse.ArgumentParser) -> None: - """Attach the ``bitwarden`` subcommand tree to a parent parser. - - Called from ``hermes_cli.main`` as part of building the top-level - ``hermes secrets`` parser. - """ - sub = parent_parser.add_subparsers(dest="secrets_bw_command") - - setup = sub.add_parser( - "setup", - help="Interactive wizard: install bws, store access token, pick project", - ) - setup.add_argument( - "--project-id", - help="Pre-select a project UUID instead of prompting", - ) - setup.add_argument( - "--access-token", - help="Provide the access token non-interactively (will be stored in .env)", - ) - setup.add_argument( - "--server-url", - help=( - "Bitwarden region / self-hosted endpoint. Examples: " - "https://vault.bitwarden.com (US, default), " - "https://vault.bitwarden.eu (EU), or your self-hosted URL. " - "Skips the interactive region prompt." - ), - ) - setup.set_defaults(func=cmd_setup) - - status = sub.add_parser( - "status", - help="Show config + binary + token validation status", - ) - status.set_defaults(func=cmd_status) - - token = sub.add_parser( - "token", - help="Rotate the access token: validate a new one and store it in .env", - ) - token.add_argument( - "--access-token", - help="Provide the new token non-interactively (default: masked prompt)", - ) - token.add_argument( - "--no-verify", - action="store_true", - help="Store without probing Bitwarden first (not recommended)", - ) - token.set_defaults(func=cmd_token) - - sync = sub.add_parser("sync", help="Fetch secrets now and report what changed") - sync.add_argument( - "--apply", - action="store_true", - help="Actually export the secrets into the current shell's env (default: dry-run)", - ) - sync.set_defaults(func=cmd_sync) - - disable = sub.add_parser("disable", help="Turn off the Bitwarden integration") - disable.set_defaults(func=cmd_disable) - - install = sub.add_parser( - "install", - help=f"Download and verify the pinned bws binary (v{_BWS_VERSION})", - ) - install.add_argument( - "--force", - action="store_true", - help="Re-download even if a managed copy already exists", - ) - install.set_defaults(func=cmd_install) + """Attach the ``bitwarden`` subcommand tree to a parent parser.""" + register_subcommands(parent_parser, "secrets_bw_command", ( + ("setup", "Interactive wizard: install bws, store access token, pick project", cmd_setup, ( + arg("--project-id", "Pre-select a project UUID instead of prompting"), + arg("--access-token", "Provide the access token non-interactively (will be stored in .env)"), + arg("--server-url", ( + "Bitwarden region / self-hosted endpoint. Examples: " + "https://vault.bitwarden.com (US, default), " + "https://vault.bitwarden.eu (EU), or your self-hosted URL. " + "Skips the interactive region prompt." + )), + )), + ("status", "Show config + binary + token validation status", cmd_status, ()), + ("token", "Rotate the access token: validate a new one and store it in .env", cmd_token, ( + arg("--access-token", "Provide the new token non-interactively (default: masked prompt)"), + flag("--no-verify", "Store without probing Bitwarden first (not recommended)"), + )), + ("sync", "Fetch secrets now and report what changed", cmd_sync, ( + flag("--apply", "Actually export the secrets into the current shell's env (default: dry-run)"), + )), + ("disable", "Turn off the Bitwarden integration", cmd_disable, ()), + ("install", f"Download and verify the pinned bws binary (v{_BWS_VERSION})", cmd_install, ( + flag("--force", "Re-download even if a managed copy already exists"), + )), + )) # --------------------------------------------------------------------------- @@ -187,15 +159,13 @@ def cmd_setup(args: argparse.Namespace) -> int: # -- non-interactive guard -- if not sys.stdin.isatty(): - missing = [] - if not (args.access_token and args.access_token.strip()): - missing.append("--access-token") - if not (args.server_url and args.server_url.strip()): - # Also accept BWS_SERVER_URL env var as non-interactive substitute - if not os.environ.get("BWS_SERVER_URL", "").strip(): - missing.append("--server-url") - if not (args.project_id and args.project_id.strip()): - missing.append("--project-id") + # BWS_SERVER_URL env var is accepted as a non-interactive substitute for --server-url. + provided = { + "--access-token": args.access_token, + "--server-url": (args.server_url or "").strip() or os.environ.get("BWS_SERVER_URL", ""), + "--project-id": args.project_id, + } + missing = [flag for flag, value in provided.items() if not (value and value.strip())] if missing: console.print( f" [red]Non-interactive mode (no TTY) requires all setup flags.[/red]\n" @@ -212,8 +182,7 @@ def cmd_setup(args: argparse.Namespace) -> int: console.print() console.print("[bold]Step 2[/bold] Provide your access token") cfg = load_config() - secrets_cfg = (cfg.setdefault("secrets", {}) - .setdefault("bitwarden", {})) + secrets_cfg = cfg.setdefault("secrets", {}).setdefault("bitwarden", {}) token_env = secrets_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") token = (args.access_token or "").strip() @@ -223,10 +192,7 @@ def cmd_setup(args: argparse.Namespace) -> int: console.print(" [red]Empty token, aborting.[/red]") return 1 if not token.startswith("0."): - console.print( - " [yellow]Warning: token doesn't start with '0.' — usually that means " - "you pasted something other than a BSM access token. Continuing anyway.[/yellow]" - ) + console.print(_NOT_BSM_TOKEN_WARNING_CONTINUING) save_env_value(token_env, token) os.environ[token_env] = token # so the test fetch below sees it @@ -247,12 +213,12 @@ def cmd_setup(args: argparse.Namespace) -> int: ) # ------------------------------------------------------------------- project - if args.project_id and args.project_id.strip(): + project_given = bool(args.project_id and args.project_id.strip()) + if project_given: project_id = args.project_id.strip() else: console.print() console.print("[bold]Step 4[/bold] Pick a project") - project_id = "" projects = _list_projects(binary, token, console, server_url=server_url) if projects is None: return 1 @@ -264,32 +230,15 @@ def cmd_setup(args: argparse.Namespace) -> int: ) return 1 - table = Table(show_header=True, header_style="bold") - table.add_column("#", style="cyan", width=4) - table.add_column("Name") - table.add_column("ID", style="dim") - for i, p in enumerate(projects, 1): - table.add_row(str(i), p.get("name", "?"), p.get("id", "?")) - console.print(table) + print_table(console, (("#", {"style": "cyan", "width": 4}), "Name", ("ID", {"style": "dim"})), + ((str(i), p.get("name", "?"), p.get("id", "?")) for i, p in enumerate(projects, 1))) - while True: - choice = console.input(f" Select project [1-{len(projects)}]: ").strip() - if not choice: - continue - try: - idx = int(choice) - except ValueError: - console.print(" [red]Enter a number.[/red]") - continue - if 1 <= idx <= len(projects): - project_id = projects[idx - 1]["id"] - break - console.print(f" [red]Out of range — pick 1-{len(projects)}.[/red]") + idx = prompt_index(console, f" Select project [1-{len(projects)}]: ", len(projects)) + project_id = projects[idx - 1]["id"] # ------------------------------------------------------------------- test console.print() - step_num = 5 if not (args.project_id and args.project_id.strip()) else 4 - console.print(f"[bold]Step {step_num}[/bold] Test fetch") + console.print(f"[bold]Step {4 if project_given else 5}[/bold] Test fetch") try: secrets, warnings = bw.fetch_bitwarden_secrets( access_token=token, @@ -305,18 +254,8 @@ def cmd_setup(args: argparse.Namespace) -> int: if not secrets: console.print(" [yellow]Fetch succeeded but the project has no secrets.[/yellow]") else: - table = Table(show_header=True, header_style="bold") - table.add_column("Name", style="cyan") - table.add_column("Status") - for key in sorted(secrets): - if key == token_env: - status = "[dim]bootstrap token — never overrides itself[/dim]" - elif os.environ.get(key): - status = "[yellow]already set in env (will be overwritten)[/yellow]" - else: - status = "[green]new[/green]" - table.add_row(key, status) - console.print(table) + print_table(console, (("Name", {"style": "cyan"}), "Status"), + ((key, _fetch_status(key, token_env)) for key in sorted(secrets))) for w in warnings: console.print(f" [yellow]warning:[/yellow] {w}") @@ -343,16 +282,27 @@ def cmd_setup(args: argparse.Namespace) -> int: return 0 +def _bw_cfg(cfg: dict) -> dict: + return section_cfg(cfg, "bitwarden") + + +def _fetch_status(key: str, token_env: str) -> str: + if key == token_env: + return "[dim]bootstrap token — never overrides itself[/dim]" + if os.environ.get(key): + return "[yellow]already set in env (will be overwritten)[/yellow]" + return "[green]new[/green]" + + def cmd_status(args: argparse.Namespace) -> int: bw = _load_bw() console = Console() - cfg = load_config() - bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} + bw_cfg = _bw_cfg(load_config()) enabled = bool(bw_cfg.get("enabled")) token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") project_id = bw_cfg.get("project_id", "") - server_url = str(bw_cfg.get("server_url", "") or "").strip() + server_url = cfg_str(bw_cfg, "server_url") token = os.environ.get(token_env, "").strip() token_set = bool(token) binary = bw.find_bws(install_if_missing=False) @@ -363,28 +313,19 @@ def cmd_status(args: argparse.Namespace) -> int: server_url=server_url, ) - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("", style="bold") - table.add_column("") - table.add_row("Enabled", _yn(enabled)) - table.add_row("Token env var", token_env) - table.add_row("Token in env", _yn(token_set)) - table.add_row("Token validation", token_validation) - table.add_row("Project ID", project_id or "[dim](unset)[/dim]") - table.add_row( - "Server URL", - server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]", - ) - table.add_row("Override existing", _yn(bool(bw_cfg.get("override_existing", False)))) - table.add_row("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))) - table.add_row("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))) - - if binary: - table.add_row("bws binary", f"{binary} ({_bws_version(binary)})") - else: - table.add_row("bws binary", "[yellow]not installed[/yellow]") - - console.print(Panel(table, title="Bitwarden Secrets Manager", border_style="cyan")) + print_status_panel(console, "Bitwarden Secrets Manager", ( + ("Enabled", _yn(enabled)), + ("Token env var", token_env), + ("Token in env", _yn(token_set)), + ("Token validation", token_validation), + ("Project ID", project_id or "[dim](unset)[/dim]"), + ("Server URL", server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]"), + ("Override existing", _yn(bool(bw_cfg.get("override_existing", False)))), + ("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))), + ("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))), + ("bws binary", + f"{binary} ({_bws_version(binary)})" if binary else "[yellow]not installed[/yellow]"), + )) for message in validation_messages: console.print(message) @@ -406,54 +347,35 @@ def cmd_status(args: argparse.Namespace) -> int: def cmd_token(args: argparse.Namespace) -> int: """Rotate the BSM access token without re-running the whole setup wizard. - Prompts for (or accepts via ``--access-token``) a new machine-account - token, probes Bitwarden with it (unless ``--no-verify``), and only then - persists it to .env — so a bad paste never bricks the working token. + Prompts for (or accepts via ``--access-token``) a new machine-account token, probes Bitwarden + with it (unless ``--no-verify``), and only then persists it to .env — so a bad paste never + bricks the working token. """ bw = _load_bw() console = Console() - cfg = load_config() - bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} + bw_cfg = _bw_cfg(load_config()) token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") - server_url = str(bw_cfg.get("server_url", "") or "").strip() + server_url = cfg_str(bw_cfg, "server_url") - token = (args.access_token or "").strip() - if not token: - if not sys.stdin.isatty(): - console.print( - "[red]No TTY — pass the token with --access-token.[/red]" - ) - return 1 - console.print( - "Create a new token in the Bitwarden web app:\n" - " Secrets Manager → Machine accounts → [your account] → " - "Access tokens → Create access token\n" - ) - token = masked_secret_prompt(f"Paste new access token ({token_env}): ").strip() - if not token: - console.print("[red]Empty token, aborting.[/red]") - return 1 - if not token.startswith("0."): - console.print( - "[yellow]Warning: token doesn't start with '0.' — usually that means " - "you pasted something other than a BSM access token.[/yellow]" - ) - - if not args.no_verify: + def verify(token: str) -> bool: + if not token.startswith("0."): + console.print(_NOT_BSM_TOKEN_WARNING) + if args.no_verify: + return True binary = bw.find_bws(install_if_missing=True) if binary is None: console.print( "[red]bws binary not available — cannot verify. " "Re-run with --no-verify to store anyway.[/red]" ) - return 1 + return False console.print("Verifying against Bitwarden…") projects = _list_projects(binary, token, console, server_url=server_url) if projects is None: console.print( "[red]✗ New token was rejected — nothing was changed.[/red]" ) - return 1 + return False console.print( f"[green]✓ Token accepted[/green] " f"({len(projects)} project{'s' if len(projects) != 1 else ''} visible)." @@ -466,35 +388,32 @@ def cmd_token(args: argparse.Namespace) -> int: "app or re-run `hermes secrets bitwarden setup` to pick a " "different project.[/yellow]" ) + return True - save_env_value(token_env, token) - os.environ[token_env] = token - # Old cached pulls are keyed on the previous token's fingerprint; drop - # them so the next startup fetches fresh with the new credential. - bw.clear_caches() - console.print( - f"[green]✓[/green] stored in {get_env_path()} as {token_env}. " - "Takes effect on the next Hermes invocation." - ) - if not bw_cfg.get("enabled"): - console.print( + return rotate_token( + console, args.access_token, token_env, + flag="--access-token", + intro=( + "Create a new token in the Bitwarden web app:\n" + " Secrets Manager → Machine accounts → [your account] → " + "Access tokens → Create access token\n" + ), + prompt=f"Paste new access token ({token_env}): ", + verify=verify, + save=save_env_value, env_path=get_env_path, clear_caches=bw.clear_caches, + disabled_note=None if bw_cfg.get("enabled") else ( "[yellow]Note: the Bitwarden integration is currently disabled — " "run `hermes secrets bitwarden setup` (or set " "secrets.bitwarden.enabled: true) to turn it on.[/yellow]" - ) - return 0 + ), + ) def cmd_sync(args: argparse.Namespace) -> int: bw = _load_bw() console = Console() - cfg = load_config() - bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} - if not bw_cfg.get("enabled"): - console.print( - "[yellow]Bitwarden integration is disabled. Run " - "`hermes secrets bitwarden setup` first.[/yellow]" - ) + bw_cfg = _bw_cfg(load_config()) + if not require_enabled(console, bw_cfg, "Bitwarden", "bitwarden"): return 1 token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") @@ -508,7 +427,7 @@ def cmd_sync(args: argparse.Namespace) -> int: console.print("[red]No project_id configured.[/red]") return 1 - server_url = str(bw_cfg.get("server_url", "") or "").strip() + server_url = cfg_str(bw_cfg, "server_url") try: secrets, warnings = bw.fetch_bitwarden_secrets( @@ -526,28 +445,24 @@ def cmd_sync(args: argparse.Namespace) -> int: return 0 override = bool(bw_cfg.get("override_existing", False)) or args.apply - table = Table(show_header=True, header_style="bold") - table.add_column("Name", style="cyan") - table.add_column("Action") + rows = [] applied = 0 for key in sorted(secrets): if key == token_env: - table.add_row(key, "[dim]skip (bootstrap token)[/dim]") + rows.append((key, "[dim]skip (bootstrap token)[/dim]")) continue already = bool(os.environ.get(key)) if already and not override: - table.add_row(key, "[dim]skip (already set)[/dim]") + rows.append((key, "[dim]skip (already set)[/dim]")) continue if args.apply: os.environ[key] = secrets[key] applied += 1 - table.add_row(key, "[green]exported[/green]" + (" (overrode)" if already else "")) + rows.append((key, "[green]exported[/green]" + (" (overrode)" if already else ""))) else: - table.add_row(key, "[green]would export[/green]" + (" (overrides)" if already else "")) + rows.append((key, "[green]would export[/green]" + (" (overrides)" if already else ""))) - console.print(table) - for w in warnings: - console.print(f"[yellow]warning:[/yellow] {w}") + print_table(console, (("Name", {"style": "cyan"}), "Action"), rows, warnings) if not args.apply: console.print( @@ -561,19 +476,13 @@ def cmd_sync(args: argparse.Namespace) -> int: def cmd_disable(args: argparse.Namespace) -> int: - console = Console() - cfg = load_config() - bw_cfg = (cfg.setdefault("secrets", {}) - .setdefault("bitwarden", {})) - bw_cfg["enabled"] = False - save_config(cfg) - console.print( + return disable_secret_source( + "bitwarden", "[green]Disabled.[/green] Bitwarden secrets will NOT be pulled on the next " "Hermes invocation.\n" " Your access token is left in .env — remove it manually if you also want " - "to revoke the credential." + "to revoke the credential.", ) - return 0 def cmd_install(args: argparse.Namespace) -> int: @@ -593,25 +502,6 @@ def cmd_install(args: argparse.Namespace) -> int: # --------------------------------------------------------------------------- -def _yn(b: bool) -> str: - return "[green]yes[/green]" if b else "[dim]no[/dim]" - - -def _bws_version(binary: Path) -> str: - try: - res = subprocess.run( - [str(binary), "--version"], - capture_output=True, - text=True, encoding='utf-8', errors='replace', - timeout=5, - ) - if res.returncode == 0: - return (res.stdout or res.stderr).strip().splitlines()[0] - except (OSError, subprocess.TimeoutExpired): - pass - return "version unknown" - - def _token_validation_status( *, enabled: bool, @@ -628,10 +518,7 @@ def _token_validation_status( messages: list[str] = [] if not token.startswith("0."): - messages.append( - " [yellow]Warning: token doesn't start with '0.' — usually that means " - "you pasted something other than a BSM access token. Continuing anyway.[/yellow]" - ) + messages.append(_NOT_BSM_TOKEN_WARNING_CONTINUING) capture = io.StringIO() probe_console = Console(file=capture, record=True, width=200) @@ -648,12 +535,8 @@ def _list_projects( binary: Path, token: str, console: Console, *, server_url: str = "" ) -> Optional[List[dict]]: """Call ``bws project list`` and return the parsed list, or None on failure.""" - # Secret-manager CLI child: intentionally receives tokens — no scrub, - # no HOME rewrite (bws stores state under the real user home). - from tools.environments.local import build_subprocess_env - env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) + env = secret_cli_env() env["BWS_ACCESS_TOKEN"] = token - env.setdefault("NO_COLOR", "1") if server_url: env["BWS_SERVER_URL"] = server_url try: @@ -713,16 +596,9 @@ def _resolve_server_url( ) -> Optional[str]: """Pick a Bitwarden server URL for setup. - Resolution order: - 1. ``--server-url`` CLI flag (non-interactive) - 2. ``BWS_SERVER_URL`` env var (so users running with that already set - in their shell don't have to re-enter it) - 3. Existing ``secrets.bitwarden.server_url`` value (for re-runs) - 4. Interactive menu: US / EU / self-hosted - - Returns the chosen URL as a string (empty string = bws default, - i.e. US Cloud). Returns None if the user aborted with an empty - custom URL. + Resolution order: 1. ``--server-url`` CLI flag (non-interactive) 2. ``BWS_SERVER_URL`` env var + (so users running with that already set in their shell don't have to re-enter it) 3. Existing + ``secrets.bitwarden.server_url`` value (for re-runs) 4. Interactive menu: US / EU / self-hosted """ if args.server_url and args.server_url.strip(): return args.server_url.strip() @@ -734,7 +610,7 @@ def _resolve_server_url( ) return env_url - existing = str(secrets_cfg.get("server_url", "") or "").strip() + existing = cfg_str(secrets_cfg, "server_url") if existing: console.print( f" Existing config: [cyan]{existing}[/cyan]. " @@ -750,36 +626,30 @@ def _resolve_server_url( console.print(table) custom_idx = len(_REGION_PRESETS) + 1 - while True: - prompt = f" Select region [1-{custom_idx}]" - if existing: - prompt += " (Enter to keep current)" - prompt += ": " - choice = console.input(prompt).strip() - if not choice: - if existing: - return existing - console.print(" [red]Enter a number.[/red]") - continue - try: - idx = int(choice) - except ValueError: - console.print(" [red]Enter a number.[/red]") - continue - if 1 <= idx <= len(_REGION_PRESETS): - return _REGION_PRESETS[idx - 1][1] - if idx == custom_idx: - custom = console.input( - " Enter your Bitwarden server URL " - "(e.g. https://vault.example.com): " - ).strip() - if not custom: - console.print(" [red]Empty URL, aborting.[/red]") - return None - if not custom.startswith(("http://", "https://")): - console.print( - " [yellow]Warning: URL doesn't start with http:// or " - "https:// — bws may reject it.[/yellow]" - ) - return custom - console.print(f" [red]Out of range — pick 1-{custom_idx}.[/red]") + prompt = f" Select region [1-{custom_idx}]" + if existing: + prompt += " (Enter to keep current)" + idx = prompt_index( + console, + prompt + ": ", + custom_idx, + allow_empty=bool(existing), + empty_message=" [red]Enter a number.[/red]", + ) + if idx == 0: + return existing + if idx <= len(_REGION_PRESETS): + return _REGION_PRESETS[idx - 1][1] + custom = console.input( + " Enter your Bitwarden server URL " + "(e.g. https://vault.example.com): " + ).strip() + if not custom: + console.print(" [red]Empty URL, aborting.[/red]") + return None + if not custom.startswith(("http://", "https://")): + console.print( + " [yellow]Warning: URL doesn't start with http:// or " + "https:// — bws may reject it.[/yellow]" + ) + return custom